This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -46,7 +46,7 @@ import {
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef } from 'react';
import type { CharacterChatModalState } from '../hooks/useStoryGeneration';
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';

View File

@@ -27,7 +27,7 @@ import {
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,

View File

@@ -29,7 +29,7 @@ import {
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
@@ -49,7 +49,7 @@ interface CustomWorldEntityCatalogProps {
previewCharacters: Character[];
activeTab: ResultTab;
onActiveTabChange: (tab: ResultTab) => void;
onEditTarget: (target: CustomWorldEditorTarget) => void;
onEditTarget: (target: RpgCreationEditorTarget) => void;
onProfileChange: (profile: CustomWorldProfile) => void;
onDeleteStoryNpcs?: (ids: string[]) => void;
onDeleteLandmarks?: (ids: string[]) => void;

View File

@@ -12,10 +12,11 @@ import type {
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
type RpgCreationEditorTarget,
RpgCreationEntityEditorModal,
} from './rpg-creation-editor/RpgCreationEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
@@ -37,12 +38,23 @@ vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('../services/aiService', () => ({
generateCustomWorldSceneImage: vi.fn(),
generateCustomWorldSceneNpc: vi.fn(),
generateInitialStory: vi.fn(),
generateNextStep: vi.fn(),
}));
vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
const generateSceneImage = vi.fn();
const generateSceneNpc = vi.fn();
return {
rpgCreationAssetClient: {
generateSceneImage,
generateSceneNpc,
},
generateCustomWorldSceneImage: generateSceneImage,
generateCustomWorldSceneNpc: generateSceneNpc,
};
});
const mockedRpgCreationAssetClient = vi.mocked(
rpgCreationAssetClient.rpgCreationAssetClient,
);
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
@@ -51,8 +63,8 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('./game-shell/GameShellRuntime', () => ({
GameShellRuntime: ({
vi.mock('./rpg-runtime-shell', () => ({
RpgRuntimeShell: ({
session,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
@@ -215,7 +227,7 @@ function createProfileWithLandmark(): CustomWorldProfile {
function LandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithLandmark());
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'landmark',
mode: 'edit',
id: 'landmark-1',
@@ -236,7 +248,7 @@ function LandmarkEditorFlowHarness() {
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
@@ -278,7 +290,7 @@ function CampEditorFlowHarness() {
],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'camp',
});
@@ -297,7 +309,7 @@ function CampEditorFlowHarness() {
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
@@ -316,7 +328,7 @@ function CoverEditorFlowHarness() {
characterRoleIds: ['playable-1'],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'cover',
});
@@ -325,7 +337,7 @@ function CoverEditorFlowHarness() {
<pre data-testid="cover-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
@@ -350,7 +362,7 @@ test('playable角色打开AI工坊后不会自动关闭', async () => {
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
@@ -372,7 +384,7 @@ test('场景角色打开AI工坊后不会自动关闭', async () => {
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
@@ -394,7 +406,7 @@ test('可扮演角色未修改时右上角关闭不会弹确认', async () => {
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
@@ -413,7 +425,7 @@ test('可扮演角色修改后右上角关闭才弹确认', async () => {
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
@@ -435,7 +447,7 @@ test('场景角色未修改时右上角关闭不会弹确认', async () => {
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
@@ -454,7 +466,7 @@ test('场景角色修改后右上角关闭才弹确认', async () => {
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
@@ -538,9 +550,8 @@ test('实体目录在空 id 列表项下不会触发重复 key 警告', () => {
});
test('场景图片保存后会同步更新编辑页和场景列表', async () => {
const aiService = await import('../services/aiService');
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/updated-scene.png',
assetId: 'asset-1',
model: 'wan2.2-t2i-flash',
@@ -573,7 +584,7 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
await user.click(screen.getByRole('button', { name: '开始生成' }));
await waitFor(() => {
expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1);
expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1);
});
await user.click(screen.getByRole('button', { name: '保存' }));
@@ -609,9 +620,8 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
const aiService = await import('../services/aiService');
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/updated-camp.png',
assetId: 'asset-camp-1',
model: 'wan2.2-t2i-flash',
@@ -644,7 +654,7 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
await user.click(screen.getByRole('button', { name: '开始生成' }));
await waitFor(() => {
expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1);
expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1);
});
await user.click(screen.getByRole('button', { name: '保存' }));

View File

@@ -6,13 +6,35 @@ import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import { CustomWorldResultView } from './CustomWorldResultView';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
vi.mock('../services/aiService', () => ({
generateCustomWorldPlayableNpc: vi.fn(),
generateCustomWorldStoryNpc: vi.fn(),
generateCustomWorldLandmark: vi.fn(),
}));
vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
const generatePlayableNpc = vi.fn();
const generateStoryNpc = vi.fn();
const generateLandmark = vi.fn();
const generateSceneImage = vi.fn();
const generateSceneNpc = vi.fn();
return {
rpgCreationAssetClient: {
generatePlayableNpc,
generateStoryNpc,
generateLandmark,
generateSceneImage,
generateSceneNpc,
},
generateCustomWorldPlayableNpc: generatePlayableNpc,
generateCustomWorldStoryNpc: generateStoryNpc,
generateCustomWorldLandmark: generateLandmark,
generateCustomWorldSceneImage: generateSceneImage,
generateCustomWorldSceneNpc: generateSceneNpc,
};
});
const mockedRpgCreationAssetClient = vi.mocked(
rpgCreationAssetClient.rpgCreationAssetClient,
);
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
@@ -24,15 +46,11 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
),
}));
vi.mock('./CustomWorldEntityEditorModal', () => ({
CustomWorldEntityEditorModal: () => null,
vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({
RpgCreationEntityEditorModal: () => null,
default: () => null,
}));
async function loadAiService() {
return import('../services/aiService');
}
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
@@ -259,7 +277,7 @@ function ResultViewHarness() {
const [profile, setProfile] = useState(baseProfile);
return (
<CustomWorldResultView
<RpgCreationResultView
profile={profile}
previewCharacters={[]}
isGenerating={false}
@@ -273,11 +291,10 @@ function ResultViewHarness() {
}
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
const aiService = await loadAiService();
const user = userEvent.setup();
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
vi.mocked(aiService.generateCustomWorldPlayableNpc).mockImplementation(
mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation(
() =>
new Promise<CustomWorldPlayableNpc>((resolve) => {
resolveGeneration = resolve;
@@ -346,7 +363,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
} as CustomWorldProfile;
render(
<CustomWorldResultView
<RpgCreationResultView
profile={profile}
previewCharacters={[
{
@@ -403,7 +420,7 @@ test('readOnly result view hides edit and create actions for agent preview mode'
const user = userEvent.setup();
render(
<CustomWorldResultView
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
@@ -425,3 +442,80 @@ test('readOnly result view hides edit and create actions for agent preview mode'
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('agent result view shows publish blockers and disables publish-enter action', () => {
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady={false}
publishBlockers={[
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
]}
qualityFindings={[
{
id: 'role-assets-pending',
severity: 'warning',
code: 'role_assets_pending',
message: '仍有角色资产未完全补齐。',
},
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/ 2 /u)).toBeTruthy();
expect(
screen.getByText(//u),
).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: '发布并进入世界',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(true);
});
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady
publishBlockers={[]}
qualityFindings={[
{
id: 'scene-assets-pending',
severity: 'warning',
code: 'scene_assets_pending',
message: '仍有场景分幕图未补齐。',
},
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
expect(screen.getByText(/ 1 warning /u)).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: '发布并进入世界',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});

View File

@@ -1,791 +0,0 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import {
generateCustomWorldLandmark,
generateCustomWorldPlayableNpc,
generateCustomWorldStoryNpc,
} from '../services/aiService';
import {
Character,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../types';
import {
CustomWorldEntityCatalog,
type ResultTab,
} from './CustomWorldEntityCatalog';
import CustomWorldEntityEditorModal, {
type CustomWorldEditorTarget,
} from './CustomWorldEntityEditorModal';
interface CustomWorldResultViewProps {
profile: CustomWorldProfile;
previewCharacters: Character[];
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onEnterWorld?: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
backLabel?: string;
editActionLabel?: string;
regenerateActionLabel?: string;
enterWorldActionLabel?: string;
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
compactAgentResultMode?: boolean;
}
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
type PendingGeneratedEntity = {
id: string;
kind: EntityGenerationKind;
title: string;
progress: number;
phaseLabel: string;
};
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,
tone = 'default',
disabled = false,
}: {
onClick: () => void;
children: ReactNode;
tone?: 'default' | 'sky';
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`${
tone === 'sky'
? 'platform-button platform-button--primary'
: 'platform-button platform-button--ghost'
} min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
);
}
function getCreateTargetByTab(
activeTab: ResultTab,
): CustomWorldEditorTarget | null {
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
return null;
}
function getCreateLabelByTab(activeTab: ResultTab) {
if (activeTab === 'playable') return '新增可扮演角色';
if (activeTab === 'story') return '新增场景角色';
if (activeTab === 'landmarks') return '新增场景';
return '';
}
function createPendingGeneratedEntity(
kind: EntityGenerationKind,
): PendingGeneratedEntity {
return {
id: `pending-${kind}-${Date.now()}`,
kind,
title:
kind === 'playable'
? '新可扮演角色'
: kind === 'story'
? '新场景角色'
: '新场景',
progress: 8,
phaseLabel: '正在整理世界上下文',
};
}
function resolvePendingPhaseLabel(
kind: EntityGenerationKind,
progress: number,
) {
if (progress < 28) {
return '正在整理世界上下文';
}
if (progress < 72) {
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
}
return '正在回写结果';
}
function prependPlayableNpc(
profile: CustomWorldProfile,
npc: CustomWorldPlayableNpc,
) {
return {
...profile,
playableNpcs: [npc, ...profile.playableNpcs],
} satisfies CustomWorldProfile;
}
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
return {
...profile,
storyNpcs: [npc, ...profile.storyNpcs],
} satisfies CustomWorldProfile;
}
function prependLandmark(
profile: CustomWorldProfile,
landmark: CustomWorldLandmark,
) {
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: [landmark, ...profile.landmarks],
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeStoryNpcsFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
return {
...profile,
storyNpcs: nextStoryNpcs,
landmarks: normalizeCustomWorldLandmarks({
landmarks: profile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
})),
storyNpcs: nextStoryNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeLandmarksFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextLandmarks = profile.landmarks.filter(
(landmark) => !idSet.has(landmark.id),
);
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: nextLandmarks.map((landmark) => ({
...landmark,
connections: landmark.connections.filter(
(connection) => !idSet.has(connection.targetLandmarkId),
),
})),
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
export function CustomWorldResultView({
profile,
previewCharacters,
isGenerating,
progress,
progressLabel,
error,
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onEnterWorld,
onProfileChange,
readOnly = false,
backLabel = '返回',
editActionLabel = '修改设定',
regenerateActionLabel = '重新生成',
enterWorldActionLabel = '进入世界',
autoSaveState = 'idle',
compactAgentResultMode = false,
}: CustomWorldResultViewProps) {
const [editorTarget, setEditorTarget] =
useState<CustomWorldEditorTarget | null>(null);
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
useState<PendingGeneratedEntity | null>(null);
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
{
playable: [],
story: [],
landmark: [],
},
);
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
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),
[activeTab],
);
const createLabel = useMemo(
() => getCreateLabelByTab(activeTab),
[activeTab],
);
const stopPendingProgressTimer = () => {
if (pendingProgressTimerRef.current !== null) {
window.clearInterval(pendingProgressTimerRef.current);
pendingProgressTimerRef.current = null;
}
};
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));
pendingProgressTimerRef.current = window.setInterval(() => {
setPendingGeneratedEntity((current) => {
if (!current || current.kind !== kind) {
return current;
}
const nextProgress = Math.min(
current.progress + (current.progress < 56 ? 11 : 5),
88,
);
return {
...current,
progress: nextProgress,
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
};
});
}, 520);
};
const finishPendingProgress = () => {
stopPendingProgressTimer();
setPendingGeneratedEntity(null);
};
const markGeneratedAsRecent = (
kind: EntityGenerationKind,
generatedId: string,
) => {
setRecentGeneratedIds((current) => ({
...current,
[kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice(
0,
6,
),
}));
};
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
if (readOnly || isGenerating || pendingGeneratedEntity) {
return;
}
setLocalGenerationError(null);
startPendingProgress(kind);
try {
if (kind === 'playable') {
const nextNpc = await generateCustomWorldPlayableNpc({ profile });
onProfileChange(prependPlayableNpc(profile, nextNpc));
markGeneratedAsRecent('playable', nextNpc.id);
} else if (kind === 'story') {
const nextNpc = await generateCustomWorldStoryNpc({ profile });
onProfileChange(prependStoryNpc(profile, nextNpc));
markGeneratedAsRecent('story', nextNpc.id);
} else {
const nextLandmark = await generateCustomWorldLandmark({ profile });
onProfileChange(prependLandmark(profile, nextLandmark));
markGeneratedAsRecent('landmark', nextLandmark.id);
}
} catch (generationError) {
setLocalGenerationError(
generationError instanceof Error
? generationError.message
: '生成失败,请稍后重试。',
);
} finally {
finishPendingProgress();
}
};
const onRegenerate = () => {
if (isGenerating || !triggerRegenerate) return;
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
);
if (!confirmed) return;
triggerRegenerate();
};
const handleDeleteStoryNpcs = (ids: string[]) => {
if (ids.length === 0) return;
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
};
const handleDeleteLandmarks = (ids: string[]) => {
if (ids.length === 0) return;
onProfileChange(removeLandmarksFromProfile(profile, ids));
};
const autoSaveBadge =
autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
>
{backLabel}
</button>
{autoSaveBadge}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={previewCharacters}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={
readOnly || compactAgentResultMode ? undefined : createLabel
}
onCreateAction={
readOnly || compactAgentResultMode || !createTarget
? undefined
: () => {
if (activeTab === 'playable') {
void handleGenerateEntity('playable');
return;
}
if (activeTab === 'story') {
void handleGenerateEntity('story');
return;
}
if (activeTab === 'landmarks') {
void handleGenerateEntity('landmark');
return;
}
setEditorTarget(createTarget);
}
}
createActionDisabled={Boolean(
isGenerating || pendingGeneratedEntity,
)}
pendingGeneratedEntity={pendingGeneratedEntity}
recentGeneratedIds={recentGeneratedIds}
readOnly={readOnly}
/>
</div>
{isGenerating && (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{progressLabel}
</div>
<div className="text-xs text-[var(--platform-text-base)]">
{Math.round(progress)}%
</div>
</div>
<div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
<div
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
</div>
)}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{!error && localGenerationError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{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' ? (
<div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
</div>
) : null}
<div className="flex items-center justify-end gap-3">
{onEditSetting ? (
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
) : null}
{triggerRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky">
{regenerateActionLabel}
</SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton
onClick={onContinueExpand}
tone="sky"
disabled={isGenerating}
>
</SmallButton>
) : null}
{onEnterWorld ? (
<button
type="button"
onClick={onEnterWorld}
disabled={isGenerating}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
{enterWorldActionLabel}
</button>
) : null}
</div>
</div>
<CustomWorldEntityEditorModal
profile={profile}
target={editorTarget}
onClose={() => setEditorTarget(null)}
onProfileChange={onProfileChange}
/>
</div>
);
}

View File

@@ -23,7 +23,7 @@ import {
getGiftCandidates,
getRarityLabel,
} from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/useStoryGeneration';
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
import { GameState, InventoryItem } from '../types';
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
import type { ComponentProps } from 'react';
import { PreGameSelectionFlow } from '../PreGameSelectionFlow';
/**
* 工作包 A 先建立 RPG 创作壳层的新命名入口。
* 当前实现继续复用旧的 `PreGameSelectionFlow`,后续工作包 B 再把内部编排逐步迁到新目录。
*/
export type RpgCreationShellProps = ComponentProps<typeof PreGameSelectionFlow>;
export function RpgCreationShell(props: RpgCreationShellProps) {
return <PreGameSelectionFlow {...props} />;
}
export default RpgCreationShell;

View File

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

View File

@@ -1,185 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
GameState,
StoryMoment,
} from '../../types';
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
type SceneTransitionRequest = {
mode: SceneTransitionTriggerMode;
baselineSceneId: string | null;
baselineContentKey: string;
exitComplete: boolean;
};
const DEFAULT_SCENE_SWITCH_EXIT_MS = 5000;
const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<Record<string, SceneTransitionTriggerMode>> = {
idle_travel_next_scene: 'scene-change',
camp_travel_home_scene: 'scene-change',
idle_explore_forward: 'content-change',
idle_follow_clue: 'content-change',
};
function buildSceneTransitionContentKey(gameState: GameState, currentStory: StoryMoment | null) {
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
const monsterKey = gameState.sceneHostileNpcs
.map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`)
.join('|');
const storyKey = currentStory
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
: 'story:none';
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
}
export function useSceneTransitionModel(params: {
gameState: GameState;
currentStory: StoryMoment | null;
openingCampSceneId: string | null;
}) {
const {
gameState,
currentStory,
openingCampSceneId,
} = params;
const [renderGameState, setRenderGameState] = useState(gameState);
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
const [sceneTransitionPhase, setSceneTransitionPhase] = useState<SceneTransitionPhase>('idle');
const [sceneTransitionToken, setSceneTransitionToken] = useState(0);
const [sceneTransitionDurations, setSceneTransitionDurations] = useState({
exitMs: DEFAULT_SCENE_SWITCH_EXIT_MS,
entryMs: DEFAULT_SCENE_SWITCH_ENTRY_MS,
});
const pendingScenePayloadRef = useRef<{ gameState: GameState; currentStory: StoryMoment | null }>({
gameState,
currentStory,
});
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
useEffect(() => {
return () => {
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
};
}, []);
const startSceneEntering = useCallback((payload: { gameState: GameState; currentStory: StoryMoment | null }) => {
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
setRenderGameState(payload.gameState);
setRenderCurrentStory(payload.currentStory);
setSceneTransitionToken(current => current + 1);
setSceneTransitionPhase('entering');
const entryTimerId = window.setTimeout(() => {
setSceneTransitionPhase('idle');
}, sceneTransitionDurations.entryMs);
sceneTransitionTimerIdsRef.current.push(entryTimerId);
}, [sceneTransitionDurations.entryMs]);
const beginSceneTransition = useCallback((mode: SceneTransitionTriggerMode) => {
if (sceneTransitionPhase !== 'idle') return;
pendingScenePayloadRef.current = { gameState, currentStory };
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = {
mode,
baselineSceneId: renderGameState.currentScenePreset?.id ?? gameState.currentScenePreset?.id ?? null,
baselineContentKey: buildSceneTransitionContentKey(renderGameState, renderCurrentStory),
exitComplete: false,
};
setSceneTransitionPhase('exiting');
const exitTimerId = window.setTimeout(() => {
const request = sceneTransitionRequestRef.current;
if (!request) return;
request.exitComplete = true;
const pendingPayload = pendingScenePayloadRef.current;
const isReady = request.mode === 'scene-change'
? (pendingPayload.gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId
: buildSceneTransitionContentKey(pendingPayload.gameState, pendingPayload.currentStory) !== request.baselineContentKey;
if (isReady) {
startSceneEntering(pendingPayload);
}
}, sceneTransitionDurations.exitMs);
sceneTransitionTimerIdsRef.current.push(exitTimerId);
}, [
currentStory,
gameState,
renderCurrentStory,
renderGameState,
sceneTransitionDurations.exitMs,
sceneTransitionPhase,
startSceneEntering,
]);
useEffect(() => {
pendingScenePayloadRef.current = { gameState, currentStory };
const request = sceneTransitionRequestRef.current;
if (sceneTransitionPhase === 'exiting' && request?.exitComplete) {
const isReady = request.mode === 'scene-change'
? (gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId
: buildSceneTransitionContentKey(gameState, currentStory) !== request.baselineContentKey;
if (isReady) {
startSceneEntering({ gameState, currentStory });
}
return;
}
if (sceneTransitionPhase !== 'exiting') {
setRenderGameState(gameState);
setRenderCurrentStory(currentStory);
}
}, [currentStory, gameState, sceneTransitionPhase, startSceneEntering]);
useEffect(() => {
if (sceneTransitionPhase !== 'idle') {
return;
}
if (renderGameState.playerCharacter) {
return;
}
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
if (gameState.storyHistory.length > 0) {
return;
}
if (!openingCampSceneId || gameState.currentScenePreset?.id !== openingCampSceneId) {
return;
}
startSceneEntering({ gameState, currentStory });
}, [
currentStory,
gameState,
openingCampSceneId,
renderGameState.playerCharacter,
sceneTransitionPhase,
startSceneEntering,
]);
return {
visibleGameState: sceneTransitionPhase === 'idle' ? gameState : renderGameState,
visibleCurrentStory: sceneTransitionPhase === 'idle' ? currentStory : renderCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
beginSceneTransition,
};
}

View File

@@ -0,0 +1,244 @@
import type { ComponentType, CSSProperties, ReactNode } from 'react';
import { RefreshCcw } from 'lucide-react';
import {
type AnimationState,
type Character,
} from '../../types';
import { CORE_ACTIONS } from './roleAssetStudioModel';
type ActionButtonProps = {
icon?: ReactNode;
label: string;
subLabel?: string;
onClick: () => void;
disabled?: boolean;
tone?: 'default' | 'sky' | 'green';
};
type FieldProps = {
label: string;
children: ReactNode;
};
type SectionProps = {
title: string;
children: ReactNode;
};
type StatusBadgeProps = {
tone: 'green' | 'amber' | 'zinc';
children: ReactNode;
};
type TextAreaProps = {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
readOnly?: boolean;
};
type CharacterAnimatorProps = {
state: AnimationState;
character: Character;
className?: string;
style?: CSSProperties;
imageClassName?: string;
playbackRate?: number;
};
export function RpgCreationRoleAnimationSection(props: {
ActionButton: (props: ActionButtonProps) => ReactNode;
CharacterAnimator: ComponentType<CharacterAnimatorProps>;
Field: (props: FieldProps) => ReactNode;
Section: (props: SectionProps) => ReactNode;
StatusBadge: (props: StatusBadgeProps) => ReactNode;
TextArea: (props: TextAreaProps) => ReactNode;
animationPreviewFrameStyle: CSSProperties;
animationPreviewPlaybackRate: number;
animationPreviewViewportStyle: CSSProperties;
animationPromptText: string;
generatingAnimationMap: Partial<Record<AnimationState, boolean>>;
hasGeneratedAnimation: (animation: AnimationState) => boolean;
isSelectedAnimationGenerating: boolean;
previewCharacter: Character | null;
previewImageSrc: string;
selectedAnimation: AnimationState;
selectedAnimationStatus: string | null;
shouldUseSelectedAnimationPreview: boolean;
syncBusy: boolean;
animationPointCost: number;
workingRoleImageSrc?: string;
workingRoleGeneratedVisualAssetId?: string;
workingRoleName: string;
onAnimationPromptChange: (value: string) => void;
onGenerateAnimation: () => void;
onPlaybackRateChange: (value: number) => void;
onSelectAnimation: (animation: AnimationState) => void;
}) {
const {
ActionButton,
CharacterAnimator,
Field,
Section,
StatusBadge,
TextArea,
animationPreviewFrameStyle,
animationPreviewPlaybackRate,
animationPreviewViewportStyle,
animationPromptText,
generatingAnimationMap,
hasGeneratedAnimation,
isSelectedAnimationGenerating,
previewCharacter,
previewImageSrc,
selectedAnimation,
selectedAnimationStatus,
shouldUseSelectedAnimationPreview,
syncBusy,
animationPointCost,
workingRoleGeneratedVisualAssetId,
workingRoleImageSrc,
workingRoleName,
onAnimationPromptChange,
onGenerateAnimation,
onPlaybackRateChange,
onSelectAnimation,
} = props;
return (
<Section title="动作">
<div className="space-y-4">
<div className="platform-role-studio__preview rounded-3xl p-4">
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
{shouldUseSelectedAnimationPreview && previewCharacter ? (
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
>
<div style={animationPreviewFrameStyle}>
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
</div>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRoleName}
className="max-h-[28rem] w-full object-contain pixelated"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
)}
</div>
</div>
<Field label="预览速度">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<input
type="range"
min="0.25"
max="1.5"
step="0.05"
value={animationPreviewPlaybackRate}
onChange={(event) =>
onPlaybackRateChange(Number.parseFloat(event.target.value) || 0.75)
}
className="w-full accent-sky-400"
/>
<div className="mt-2 flex items-center justify-between text-[11px] text-zinc-400">
<span>0.25x</span>
<span>{animationPreviewPlaybackRate.toFixed(2)}x</span>
<span>1.50x</span>
</div>
</div>
</Field>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(item.animation);
const isGenerating = generatingAnimationMap[item.animation] === true;
return (
<button
key={item.animation}
type="button"
onClick={() => onSelectAnimation(item.animation)}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
isSelected
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{item.label}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
<span>{item.required ? '必需动作' : '可选动作'}</span>
</div>
</div>
<StatusBadge
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
>
{isGenerating
? '生成中'
: isReady
? '已生成'
: item.required
? '待生成'
: (item.fallbackStatusLabel ?? '可选')}
</StatusBadge>
</div>
</button>
);
})}
</div>
<Field label="动作描述">
<TextArea
value={animationPromptText}
onChange={onAnimationPromptChange}
rows={5}
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
/>
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世币`}
onClick={onGenerateAnimation}
disabled={
isSelectedAnimationGenerating ||
!workingRoleImageSrc ||
!workingRoleGeneratedVisualAssetId ||
syncBusy
}
tone="sky"
/>
</div>
{selectedAnimationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{selectedAnimationStatus}
</div>
) : null}
</div>
</Section>
);
}
export default RpgCreationRoleAnimationSection;

View File

@@ -0,0 +1,53 @@
export function RpgCreationRoleAssetStudioFooter(props: {
isSavingToRole: boolean;
saveStatus: string | null;
syncBusy: boolean;
workingRoleGeneratedVisualAssetId?: string;
workingRoleImageSrc?: string;
onSaveToRole: () => void;
}) {
const {
isSavingToRole,
saveStatus,
syncBusy,
workingRoleGeneratedVisualAssetId,
workingRoleImageSrc,
onSaveToRole,
} = props;
return (
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
<div className="space-y-3">
{saveStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{saveStatus}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onSaveToRole}
disabled={
isSavingToRole ||
syncBusy ||
!workingRoleImageSrc ||
!workingRoleGeneratedVisualAssetId
}
className={`rounded-full border border-emerald-400/30 bg-emerald-500/10 px-5 py-2 text-sm font-semibold text-emerald-100 transition-colors hover:bg-emerald-500/20 ${
isSavingToRole ||
syncBusy ||
!workingRoleImageSrc ||
!workingRoleGeneratedVisualAssetId
? 'cursor-not-allowed opacity-45'
: ''
}`}
>
{isSavingToRole || syncBusy ? '保存中...' : '保存到当前角色'}
</button>
</div>
</div>
</div>
);
}
export default RpgCreationRoleAssetStudioFooter;

View File

@@ -0,0 +1,21 @@
import type { ComponentProps } from 'react';
import {
RpgCreationRoleAssetStudioModal as RpgCreationRoleAssetStudioModalImpl,
} from './RpgCreationRoleAssetStudioModalImpl';
/**
* 工作包 C 完成后,角色资产工坊 façade 已直接桥接 RPG 创作目录下的真实实现。
* 旧入口仍保留兼容导出,后续视觉/动作工作流继续在该目录内部演进。
*/
export type RpgCreationRoleAssetStudioModalProps = ComponentProps<
typeof RpgCreationRoleAssetStudioModalImpl
>;
export function RpgCreationRoleAssetStudioModal(
props: RpgCreationRoleAssetStudioModalProps,
) {
return <RpgCreationRoleAssetStudioModalImpl {...props} />;
}
export default RpgCreationRoleAssetStudioModal;

View File

@@ -1,4 +1,3 @@
import { ImagePlus, RefreshCcw } from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
@@ -9,109 +8,33 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { ROLE_TEMPLATE_CHARACTERS } from '../../data/characterPresets';
import {
AnimationState,
type Character,
type CharacterAnimationConfig,
} from '../types';
} from '../../types';
import { readFileAsDataUrl } from '../asset-studio/characterAssetWorkflowModel';
import {
buildAnimationClipFromVideoSource,
readFileAsDataUrl,
} from './asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationGenerationPayload,
type CharacterAssetWorkflowCache,
type CharacterVisualDraft,
fetchCharacterWorkflowCache,
generateCharacterAnimationDraft,
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
saveCharacterWorkflowCache,
} from './asset-studio/characterAssetWorkflowPersistence';
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
import { useAuthUi } from './auth/AuthUiContext';
import { CharacterAnimator } from './CharacterAnimator';
type EditableCustomWorldRole = {
id: string;
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAiActionConfig = {
animation: AnimationState;
label: string;
templateId: string;
fps: number;
frameCount: number;
durationSeconds: number;
loop: boolean;
required: boolean;
fallbackStatusLabel?: string;
};
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.RUN,
label: '奔跑',
templateId: 'run',
fps: 12,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: true,
},
{
animation: AnimationState.ATTACK,
label: '攻击',
templateId: 'attack_slash',
fps: 12,
frameCount: 8,
durationSeconds: 4,
loop: false,
required: true,
},
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: false,
fallbackStatusLabel: '默认静止',
},
{
animation: AnimationState.DIE,
label: '死亡',
templateId: 'die',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: false,
required: false,
fallbackStatusLabel: '默认倒地动画',
},
];
} from '../asset-studio/characterAssetWorkflowPersistence';
import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePromptDefaults';
import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
import {
CORE_ACTIONS,
type CustomWorldAiActionConfig,
type EditableCustomWorldRole,
} from './roleAssetStudioModel';
import { useRoleAnimationWorkflow } from './useRoleAnimationWorkflow';
import { useRoleVisualCandidateWorkflow } from './useRoleVisualCandidateWorkflow';
const DEFAULT_ANIMATION_PLAYBACK_RATE = 0.75;
const MIN_ANIMATION_PLAYBACK_RATE = 0.25;
@@ -527,16 +450,7 @@ function buildAnimationPreviewCharacter(params: {
} as Character;
}
export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onPublishSuccess,
onClose,
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
}: {
export interface RpgCreationRoleAssetStudioModalProps {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply?: (nextRole: EditableCustomWorldRole) => void;
@@ -556,7 +470,18 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
}) {
}
export function RpgCreationRoleAssetStudioModal({
role,
roleKind,
onApply,
onPublishSuccess,
onClose,
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
}: RpgCreationRoleAssetStudioModalProps) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const baseRole = useMemo<EditableCustomWorldRole>(
() => ({
@@ -637,6 +562,10 @@ export function CustomWorldRoleAssetStudioModal({
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [isSavingToRole, setIsSavingToRole] = useState(false);
const [isHydratingCache, setIsHydratingCache] = useState(true);
const { applyVisualDraftToRole, generateVisualCandidatesForRole } =
useRoleVisualCandidateWorkflow();
const { generateAnimationClipForRole, publishAnimationClipForRole } =
useRoleAnimationWorkflow();
const selectedTemplate =
roleKind === 'playable' && workingRole.templateCharacterId
@@ -911,15 +840,11 @@ export function CustomWorldRoleAssetStudioModal({
const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => {
setIsApplyingVisual(true);
try {
const result = await publishCharacterVisualAsset({
characterId: workingRole.id,
sourceMode: visualSourceMode,
const result = await applyVisualDraftToRole({
draft,
promptText: visualPromptText,
selectedPreviewSource: draft.imageSrc,
previewSources: [draft.imageSrc],
width: draft.width,
height: draft.height,
updateCharacterOverride: false,
role: workingRole,
sourceMode: visualSourceMode,
});
const nextRole = mergeRole(workingRole, {
@@ -954,15 +879,12 @@ export function CustomWorldRoleAssetStudioModal({
setVisualStatus(null);
try {
const result = await generateCharacterVisualCandidates({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
const result = await generateVisualCandidatesForRole({
characterBriefText,
promptText: visualPromptText,
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
candidateCount: 1,
imageModel: 'wan2.7-image-pro',
size: '1024*1024',
role: workingRole,
sourceMode: visualSourceMode,
});
setVisualDrafts(result.drafts);
if (result.drafts[0]) {
@@ -981,50 +903,6 @@ export function CustomWorldRoleAssetStudioModal({
}
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
throw new Error('请先生成角色形象,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: workingRole.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: workingRole.imageSrc,
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
loop: config.loop,
useChromaKey: true,
resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (result.strategy !== 'image-to-video') {
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
}
return buildAnimationClipFromVideoSource(result.previewVideoPath, {
animation: config.animation,
fps: config.fps,
loop: config.loop,
frameCount: config.frameCount,
applyChromaKey: true,
sampleStartRatio: config.loop ? 0.12 : 0,
sampleEndRatio: config.loop ? 0.94 : 1,
});
};
const handleGenerateAnimation = async () => {
if (!selectedActionConfig) {
return;
@@ -1056,21 +934,16 @@ export function CustomWorldRoleAssetStudioModal({
}));
try {
const clip = await generateActionClip(actionConfig);
const result = await publishCharacterAnimationAssets({
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId!,
animations: {
[actionConfig.animation]: {
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
previewVideoPath: clip.previewVideoPath,
},
},
updateCharacterOverride: false,
const clip = await generateAnimationClipForRole({
actionConfig,
animationPromptText,
characterBriefText,
role: workingRole,
});
const result = await publishAnimationClipForRole({
actionConfig,
clip,
role: workingRole,
});
setWorkingRole((current) =>
@@ -1168,297 +1041,95 @@ export function CustomWorldRoleAssetStudioModal({
}
>
<div className="space-y-5">
<Section title="角色形象">
<div className="space-y-4">
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
<img
src={selectedTemplate.portrait}
alt={selectedTemplate.name}
className="max-h-[20rem] w-full object-contain"
/>
) : (
<div className="px-6 text-center text-sm text-zinc-500">
</div>
)}
</div>
</div>
<RpgCreationRoleVisualSection
ActionButton={ActionButton}
Field={Field}
Section={Section}
TextArea={TextArea}
handleReferenceImageUpload={handleReferenceImageUpload}
hasGeneratedVisualPreview={hasGeneratedVisualPreview}
isApplyingVisual={isApplyingVisual}
isGeneratingVisuals={isGeneratingVisuals}
previewImageSrc={previewImageSrc}
referenceImageDataUrls={referenceImageDataUrls}
selectedTemplatePortrait={selectedTemplate?.portrait}
selectedTemplateName={selectedTemplate?.name}
syncBusy={syncBusy}
visualPointCost={visualPointCost}
visualPromptText={visualPromptText}
visualStatus={visualStatus}
workingRoleName={workingRole.name}
onClearReferenceImages={() => setReferenceImageDataUrls([])}
onGenerateVisuals={() => {
void handleGenerateVisuals();
}}
onVisualPromptChange={setVisualPromptText}
/>
<Field label="形象描述">
<TextArea
value={visualPromptText}
onChange={setVisualPromptText}
rows={6}
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
/>
</Field>
<RpgCreationRoleAnimationSection
ActionButton={ActionButton}
CharacterAnimator={CharacterAnimator}
Field={Field}
Section={Section}
StatusBadge={StatusBadge}
TextArea={TextArea}
animationPreviewFrameStyle={animationPreviewFrameStyle}
animationPreviewPlaybackRate={animationPreviewPlaybackRate}
animationPreviewViewportStyle={animationPreviewViewportStyle}
animationPromptText={animationPromptText}
generatingAnimationMap={generatingAnimationMap}
hasGeneratedAnimation={(animation) =>
hasGeneratedAnimation(workingRole, animation)
}
isSelectedAnimationGenerating={isSelectedAnimationGenerating}
previewCharacter={previewCharacter}
previewImageSrc={previewImageSrc}
selectedAnimation={selectedAnimation}
selectedAnimationStatus={selectedAnimationStatus}
shouldUseSelectedAnimationPreview={shouldUseSelectedAnimationPreview}
syncBusy={syncBusy}
animationPointCost={animationPointCost}
workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId}
workingRoleImageSrc={workingRole.imageSrc}
workingRoleName={workingRole.name}
onAnimationPromptChange={setAnimationPromptText}
onGenerateAnimation={() => {
void handleGenerateAnimation();
}}
onPlaybackRateChange={(value) => {
const nextPlaybackRate = clampAnimationPlaybackRate(value);
setAnimationPreviewPlaybackRate(nextPlaybackRate);
setSaveStatus(null);
setWorkingRole((current) => {
const nextAnimationMap = applyPlaybackRateToAnimationMap({
animationMap: current.animationMap as
| Record<string, unknown>
| undefined,
animation: selectedAnimation,
actionConfig: selectedActionConfig,
playbackRate: nextPlaybackRate,
});
<Field label="参考图">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={handleReferenceImageUpload}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageDataUrls.length > 0 ? (
<div className="mt-3 space-y-3">
<div className="flex flex-wrap gap-2">
{referenceImageDataUrls.map((imageSrc, index) => (
<div
key={`${imageSrc}-${index}`}
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
>
<img
src={imageSrc}
alt={`reference-${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div>
<ActionButton
label="清空参考图"
onClick={() => setReferenceImageDataUrls([])}
disabled={
isGeneratingVisuals || isApplyingVisual || syncBusy
}
/>
</div>
</div>
) : null}
</Field>
return nextAnimationMap === current.animationMap
? current
: mergeRole(current, {
animationMap: nextAnimationMap,
});
});
}}
onSelectAnimation={setSelectedAnimation}
/>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={
hasGeneratedVisualPreview ? (
<RefreshCcw className="h-4 w-4" />
) : (
<ImagePlus className="h-4 w-4" />
)
}
label={
isGeneratingVisuals
? '生成中...'
: hasGeneratedVisualPreview
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}叙世币`}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"
/>
</div>
{visualStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{visualStatus}
</div>
) : null}
</div>
</Section>
<Section title="动作">
<div className="space-y-4">
<div className="platform-role-studio__preview rounded-3xl p-4">
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
{shouldUseSelectedAnimationPreview && previewCharacter ? (
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
>
<div style={animationPreviewFrameStyle}>
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
</div>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain pixelated"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
)}
</div>
</div>
<Field label="预览速度">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<input
type="range"
min="0.25"
max="1.5"
step="0.05"
value={animationPreviewPlaybackRate}
onChange={(event) => {
const nextPlaybackRate = clampAnimationPlaybackRate(
Number.parseFloat(event.target.value) ||
DEFAULT_ANIMATION_PLAYBACK_RATE,
);
setAnimationPreviewPlaybackRate(nextPlaybackRate);
setSaveStatus(null);
setWorkingRole((current) => {
const nextAnimationMap = applyPlaybackRateToAnimationMap({
animationMap: current.animationMap as
| Record<string, unknown>
| undefined,
animation: selectedAnimation,
actionConfig: selectedActionConfig,
playbackRate: nextPlaybackRate,
});
return nextAnimationMap === current.animationMap
? current
: mergeRole(current, {
animationMap: nextAnimationMap,
});
});
}}
className="w-full accent-sky-400"
/>
<div className="mt-2 flex items-center justify-between text-[11px] text-zinc-400">
<span>0.25x</span>
<span>{animationPreviewPlaybackRate.toFixed(2)}x</span>
<span>1.50x</span>
</div>
</div>
</Field>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(
workingRole,
item.animation,
);
const isGenerating =
generatingAnimationMap[item.animation] === true;
return (
<button
key={item.animation}
type="button"
onClick={() => setSelectedAnimation(item.animation)}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
isSelected
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{item.label}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
<span>{item.required ? '必需动作' : '可选动作'}</span>
</div>
</div>
<StatusBadge
tone={
isGenerating ? 'amber' : isReady ? 'green' : 'zinc'
}
>
{isGenerating
? '生成中'
: isReady
? '已生成'
: item.required
? '待生成'
: (item.fallbackStatusLabel ?? '可选')}
</StatusBadge>
</div>
</button>
);
})}
</div>
<Field label="动作描述">
<TextArea
value={animationPromptText}
onChange={setAnimationPromptText}
rows={5}
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
/>
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世币`}
onClick={() => void handleGenerateAnimation()}
disabled={
isSelectedAnimationGenerating ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId ||
syncBusy
}
tone="sky"
/>
</div>
{selectedAnimationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{selectedAnimationStatus}
</div>
) : null}
</div>
</Section>
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
<div className="space-y-3">
{saveStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{saveStatus}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => void handleSaveToRole()}
disabled={
isSavingToRole ||
syncBusy ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId
}
className={`rounded-full border border-emerald-400/30 bg-emerald-500/10 px-5 py-2 text-sm font-semibold text-emerald-100 transition-colors hover:bg-emerald-500/20 ${
isSavingToRole ||
syncBusy ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId
? 'cursor-not-allowed opacity-45'
: ''
}`}
>
{isSavingToRole || syncBusy ? '保存中...' : '保存到当前角色'}
</button>
</div>
</div>
</div>
<RpgCreationRoleAssetStudioFooter
isSavingToRole={isSavingToRole}
saveStatus={saveStatus}
syncBusy={syncBusy}
workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId}
workingRoleImageSrc={workingRole.imageSrc}
onSaveToRole={() => {
void handleSaveToRole();
}}
/>
</div>
</PortalModalShell>
);

View File

@@ -0,0 +1,184 @@
import type { ChangeEvent, ReactNode } from 'react';
import { ImagePlus, RefreshCcw } from 'lucide-react';
type ActionButtonProps = {
icon?: ReactNode;
label: string;
subLabel?: string;
onClick: () => void;
disabled?: boolean;
tone?: 'default' | 'sky' | 'green';
};
type FieldProps = {
label: string;
children: ReactNode;
};
type TextAreaProps = {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
readOnly?: boolean;
};
type SectionProps = {
title: string;
children: ReactNode;
};
export function RpgCreationRoleVisualSection(props: {
ActionButton: (props: ActionButtonProps) => ReactNode;
Field: (props: FieldProps) => ReactNode;
Section: (props: SectionProps) => ReactNode;
TextArea: (props: TextAreaProps) => ReactNode;
handleReferenceImageUpload: (
event: ChangeEvent<HTMLInputElement>,
) => Promise<void>;
hasGeneratedVisualPreview: boolean;
isApplyingVisual: boolean;
isGeneratingVisuals: boolean;
previewImageSrc: string;
referenceImageDataUrls: string[];
selectedTemplatePortrait?: string | null;
selectedTemplateName?: string | null;
syncBusy: boolean;
visualPointCost: number;
visualPromptText: string;
visualStatus: string | null;
workingRoleName: string;
onClearReferenceImages: () => void;
onGenerateVisuals: () => void;
onVisualPromptChange: (value: string) => void;
}) {
const {
ActionButton,
Field,
Section,
TextArea,
handleReferenceImageUpload,
hasGeneratedVisualPreview,
isApplyingVisual,
isGeneratingVisuals,
previewImageSrc,
referenceImageDataUrls,
selectedTemplateName,
selectedTemplatePortrait,
syncBusy,
visualPointCost,
visualPromptText,
visualStatus,
workingRoleName,
onClearReferenceImages,
onGenerateVisuals,
onVisualPromptChange,
} = props;
return (
<Section title="角色形象">
<div className="space-y-4">
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRoleName}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplatePortrait ? (
<img
src={selectedTemplatePortrait}
alt={selectedTemplateName ?? workingRoleName}
className="max-h-[20rem] w-full object-contain"
/>
) : (
<div className="px-6 text-center text-sm text-zinc-500">
</div>
)}
</div>
</div>
<Field label="形象描述">
<TextArea
value={visualPromptText}
onChange={onVisualPromptChange}
rows={6}
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
/>
</Field>
<Field label="参考图">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => {
void handleReferenceImageUpload(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageDataUrls.length > 0 ? (
<div className="mt-3 space-y-3">
<div className="flex flex-wrap gap-2">
{referenceImageDataUrls.map((imageSrc, index) => (
<div
key={`${imageSrc}-${index}`}
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
>
<img
src={imageSrc}
alt={`reference-${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div>
<ActionButton
label="清空参考图"
onClick={onClearReferenceImages}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
/>
</div>
</div>
) : null}
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={
hasGeneratedVisualPreview ? (
<RefreshCcw className="h-4 w-4" />
) : (
<ImagePlus className="h-4 w-4" />
)
}
label={
isGeneratingVisuals
? '生成中...'
: hasGeneratedVisualPreview
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}叙世币`}
onClick={onGenerateVisuals}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"
/>
</div>
{visualStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{visualStatus}
</div>
) : null}
</div>
</Section>
);
}
export default RpgCreationRoleVisualSection;

View File

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

View File

@@ -0,0 +1,79 @@
import { AnimationState } from '../../types';
export type EditableCustomWorldRole = {
id: string;
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
export type CustomWorldAiActionConfig = {
animation: AnimationState;
label: string;
templateId: string;
fps: number;
frameCount: number;
durationSeconds: number;
loop: boolean;
required: boolean;
fallbackStatusLabel?: string;
};
export const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.RUN,
label: '奔跑',
templateId: 'run',
fps: 12,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: true,
},
{
animation: AnimationState.ATTACK,
label: '攻击',
templateId: 'attack_slash',
fps: 12,
frameCount: 8,
durationSeconds: 4,
loop: false,
required: true,
},
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: false,
fallbackStatusLabel: '默认静止',
},
{
animation: AnimationState.DIE,
label: '死亡',
templateId: 'die',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: false,
required: false,
fallbackStatusLabel: '默认倒地动画',
},
];

View File

@@ -0,0 +1,13 @@
import {
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from '../asset-studio/characterAssetWorkflowPersistence';
/**
* 工作包 C 第一轮先把发布相关 API 出口收口到独立 client。
* 后续场景资产工坊复用时,可以直接沿用这里的发布边界。
*/
export const roleAssetStudioPublishClient = {
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
};

View File

@@ -0,0 +1,99 @@
import {
buildAnimationClipFromVideoSource,
type DraftAnimationClip,
} from '../asset-studio/characterAssetWorkflowModel';
import { generateCharacterAnimationDraft } from '../asset-studio/characterAssetWorkflowPersistence';
import type { CharacterAnimationGenerationPayload } from '../asset-studio/characterAssetWorkflowPersistence';
import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient';
import type {
CustomWorldAiActionConfig,
EditableCustomWorldRole,
} from './roleAssetStudioModel';
export function useRoleAnimationWorkflow() {
const generateAnimationClipForRole = async (params: {
actionConfig: CustomWorldAiActionConfig;
animationPromptText: string;
characterBriefText: string;
role: EditableCustomWorldRole;
}): Promise<DraftAnimationClip> => {
const { actionConfig, animationPromptText, characterBriefText, role } =
params;
if (!role.imageSrc || !role.generatedVisualAssetId) {
throw new Error('请先生成角色形象,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: role.id,
strategy: 'image-to-video',
animation: actionConfig.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: actionConfig.templateId,
visualSource: role.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: role.imageSrc,
frameCount: actionConfig.frameCount,
fps: actionConfig.fps,
durationSeconds: actionConfig.durationSeconds,
loop: actionConfig.loop,
useChromaKey: true,
resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (result.strategy !== 'image-to-video') {
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
}
return buildAnimationClipFromVideoSource(result.previewVideoPath, {
animation: actionConfig.animation,
fps: actionConfig.fps,
loop: actionConfig.loop,
frameCount: actionConfig.frameCount,
applyChromaKey: true,
sampleStartRatio: actionConfig.loop ? 0.12 : 0,
sampleEndRatio: actionConfig.loop ? 0.94 : 1,
});
};
const publishAnimationClipForRole = async (params: {
actionConfig: CustomWorldAiActionConfig;
clip: DraftAnimationClip;
role: EditableCustomWorldRole;
}) => {
const { actionConfig, clip, role } = params;
if (!role.generatedVisualAssetId) {
throw new Error('缺少角色主图资产,无法发布动作。');
}
return roleAssetStudioPublishClient.publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
animations: {
[actionConfig.animation]: {
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
previewVideoPath: clip.previewVideoPath,
},
},
updateCharacterOverride: false,
});
};
return {
generateAnimationClipForRole,
publishAnimationClipForRole,
};
}

View File

@@ -0,0 +1,59 @@
import { generateCharacterVisualCandidates } from '../asset-studio/characterAssetWorkflowPersistence';
import type { CharacterVisualDraft } from '../asset-studio/characterAssetWorkflowPersistence';
import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient';
import type { EditableCustomWorldRole } from './roleAssetStudioModel';
export function useRoleVisualCandidateWorkflow() {
const generateVisualCandidatesForRole = async (params: {
characterBriefText: string;
promptText: string;
referenceImageDataUrls: string[];
role: EditableCustomWorldRole;
sourceMode: 'text-to-image' | 'image-to-image';
}) => {
const {
characterBriefText,
promptText,
referenceImageDataUrls,
role,
sourceMode,
} = params;
return generateCharacterVisualCandidates({
characterId: role.id,
sourceMode,
promptText,
characterBriefText,
referenceImageDataUrls,
candidateCount: 1,
imageModel: 'wan2.7-image-pro',
size: '1024*1024',
});
};
const applyVisualDraftToRole = async (params: {
draft: CharacterVisualDraft;
promptText: string;
role: EditableCustomWorldRole;
sourceMode: 'text-to-image' | 'image-to-image';
}) => {
const { draft, promptText, role, sourceMode } = params;
return roleAssetStudioPublishClient.publishCharacterVisualAsset({
characterId: role.id,
sourceMode,
promptText,
selectedPreviewSource: draft.imageSrc,
previewSources: [draft.imageSrc],
width: draft.width,
height: draft.height,
updateCharacterOverride: false,
});
};
return {
applyVisualDraftToRole,
generateVisualCandidatesForRole,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,24 @@
import type { ComponentProps } from 'react';
import CustomWorldEntityEditorModal from '../CustomWorldEntityEditorModal';
import RpgCreationEntityEditorModalImpl from './RpgCreationEntityEditorModalImpl';
import type {
RpgCreationEditorTarget,
} from './RpgCreationEntityEditorModalImpl';
/**
* 工作包 A 只建立 RPG 创作编辑器的新目录入口
* 真实编辑表单暂时仍由旧的综合编辑器承载,后续工作包 C 会把不同 section 拆到这个目录下。
* 工作包 C 完成后,编辑器 façade 已直接桥接 RPG 创作目录下的目标分发壳层
* 旧 `CustomWorldEntityEditorModal.tsx` 兼容入口已经删除,当前继续保留 shared 实现承载复杂表单细节,
* 后续可在不改入口的前提下继续物理拆分 section。
*/
export type RpgCreationEntityEditorModalProps = ComponentProps<
typeof CustomWorldEntityEditorModal
typeof RpgCreationEntityEditorModalImpl
>;
export function RpgCreationEntityEditorModal(
props: RpgCreationEntityEditorModalProps,
) {
return <CustomWorldEntityEditorModal {...props} />;
return <RpgCreationEntityEditorModalImpl {...props} />;
}
export type { RpgCreationEditorTarget };
export default RpgCreationEntityEditorModal;

View File

@@ -0,0 +1,136 @@
import type { CustomWorldProfile } from '../../types';
import CampSceneEditor from './CustomWorldCampEditorSection';
import WorldCoverEditor from './CustomWorldCoverEditorSection';
import LandmarkEditor from './CustomWorldLandmarkEditorSection';
import PlayableNpcEditor, {
StoryNpcEditor,
} from './CustomWorldRoleEditorSection';
import WorldEditor from './CustomWorldWorldEditorSection';
import {
resolveEditableLandmark,
resolveEditablePlayableNpc,
resolveEditableStoryNpc,
} from './rpgCreationResultFormMapper';
export type RpgCreationEditorTarget =
| { kind: 'world' }
| { kind: 'cover' }
| { kind: 'camp' }
| { kind: 'playable'; mode: 'create' }
| { kind: 'playable'; mode: 'edit'; id: string }
| { kind: 'story'; mode: 'create' }
| { kind: 'story'; mode: 'edit'; id: string }
| { kind: 'landmark'; mode: 'create' }
| { kind: 'landmark'; mode: 'edit'; id: string };
export interface RpgCreationEntityEditorModalProps {
profile: CustomWorldProfile;
target: RpgCreationEditorTarget | null;
onClose: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
/**
* 工作包 C 收口后的编辑器主入口只负责目标分发。
* 具体 section 实现已经下沉到 shared/section 文件,后续可以继续物理拆分而不再膨胀主壳层。
*/
export function RpgCreationEntityEditorModal({
profile,
target,
onClose,
onProfileChange,
}: RpgCreationEntityEditorModalProps) {
if (!target) {
return null;
}
if (target.kind === 'world') {
return (
<WorldEditor
profile={profile}
onSave={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'cover') {
return (
<WorldCoverEditor
profile={profile}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'camp') {
return (
<CampSceneEditor
profile={profile}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'playable') {
const npc = resolveEditablePlayableNpc(profile, target);
return npc ? (
<PlayableNpcEditor
profile={profile}
npc={npc}
mode={target.mode}
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs:
target.mode === 'create'
? [...profile.playableNpcs, nextNpc]
: profile.playableNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.kind === 'story') {
const npc = resolveEditableStoryNpc(profile, target);
return npc ? (
<StoryNpcEditor
profile={profile}
npc={npc}
mode={target.mode}
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs:
target.mode === 'create'
? [...profile.storyNpcs, nextNpc]
: profile.storyNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
const landmark = resolveEditableLandmark(profile, target);
return landmark ? (
<LandmarkEditor
profile={profile}
landmark={landmark}
mode={target.mode}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
) : null;
}
export default RpgCreationEntityEditorModal;

View File

@@ -4,43 +4,41 @@ import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
} from '../data/characterPresets';
} from '../../data/characterPresets';
import {
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
} from '../../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import { RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../editor/shared/editorApiClient';
import { fetchJson } from '../editor/shared/jsonClient';
import { useCombatFlow } from '../hooks/useCombatFlow';
import { useGameFlow } from '../hooks/useGameFlow';
import { useNpcInteractionFlow } from '../hooks/useNpcInteractionFlow';
import { useStoryGeneration } from '../hooks/useStoryGeneration';
import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts';
import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/aiService';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
} from '../../data/customWorldVisuals';
import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story';
import { useRpgSessionBootstrap } from '../../hooks/rpg-session';
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
import {
buildDefaultCustomWorldCoverProfile,
resolveCustomWorldCoverPresentation,
} from '../services/customWorldCover';
} from '../../services/customWorldCover';
import {
type CustomWorldCoverAssetResult,
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from '../services/customWorldCoverAssetService';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
} from '../../services/customWorldCoverAssetService';
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
type Character,
@@ -63,30 +61,38 @@ import {
type SceneNpc,
type ScenePresetInfo,
WorldType,
} from '../types';
import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel';
} from '../../types';
import { buildAnimationClipFromVideoSource } from '../asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft,
publishCharacterAnimationAssets,
} from './asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from './auth/AuthUiContext';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
} from '../asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { buildDefaultCustomWorldNpcVisual } from '../customWorldNpcVisualDefaults';
import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
} from '../CustomWorldNpcVisualEditor';
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
import {
RoleCharacterSprite,
SceneEncounterNpcSprite,
} from './game-canvas/GameCanvasShared';
import { GameShellRuntime } from './game-shell/GameShellRuntime';
import { PixelIcon } from './PixelIcon';
} from '../game-canvas/GameCanvasShared';
import { PixelIcon } from '../PixelIcon';
import { RpgRuntimeShell } from '../rpg-runtime-shell';
import {
createLandmarkDraft,
createPlayableNpcDraft,
createStoryNpcDraft,
resolveEditableLandmark,
resolveEditablePlayableNpc,
resolveEditableStoryNpc,
} from './rpgCreationResultFormMapper';
export type CustomWorldEditorTarget =
export type RpgCreationEditorTarget =
| { kind: 'world' }
| { kind: 'cover' }
| { kind: 'camp' }
@@ -97,9 +103,9 @@ export type CustomWorldEditorTarget =
| { kind: 'landmark'; mode: 'create' }
| { kind: 'landmark'; mode: 'edit'; id: string };
interface CustomWorldEntityEditorModalProps {
export interface RpgCreationEntityEditorModalProps {
profile: CustomWorldProfile;
target: CustomWorldEditorTarget | null;
target: RpgCreationEditorTarget | null;
onClose: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
@@ -1144,6 +1150,7 @@ function ImagePreview({
fallbackLabel,
tone = 'square',
children,
previewOverlay,
overlayInteractive = false,
}: {
src?: string;
@@ -1151,6 +1158,7 @@ function ImagePreview({
fallbackLabel: string;
tone?: 'square' | 'landscape';
children?: ReactNode;
previewOverlay?: ReactNode;
overlayInteractive?: boolean;
}) {
return (
@@ -1169,10 +1177,11 @@ function ImagePreview({
{fallbackLabel}
</div>
)}
{children ? (
{children || previewOverlay ? (
<div
className={`${overlayInteractive ? 'pointer-events-auto' : 'pointer-events-none'} absolute inset-0`}
>
{previewOverlay}
{children}
</div>
) : null}
@@ -1737,11 +1746,11 @@ function SceneActPreviewRuntime({
resetGame,
handleCustomWorldSelect,
handleCharacterSelect,
} = useGameFlow();
} = useRpgSessionBootstrap();
const combatFlow = useCombatFlow({
setGameState,
});
const storyFlow = useStoryGeneration({
const storyFlow = useRpgRuntimeStory({
gameState,
setGameState,
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
@@ -1837,7 +1846,7 @@ function SceneActPreviewRuntime({
}
return (
<GameShellRuntime
<RpgRuntimeShell
session={{
gameState,
currentStory: storyFlow.currentStory,
@@ -2461,7 +2470,7 @@ function SceneImageGenerationModal({
setError(null);
try {
const result = await generateCustomWorldSceneImage({
const result = await rpgCreationAssetClient.generateSceneImage({
profile,
landmark,
userPrompt,
@@ -3149,7 +3158,7 @@ function CoverImageGenerationModal({
);
}
function WorldCoverEditor({
export function WorldCoverEditor({
profile,
onSaveProfile,
onClose,
@@ -3358,7 +3367,7 @@ function WorldCoverEditor({
);
}
function SaveBar({
export function SaveBar({
onClose,
onSave,
extraAction,
@@ -3404,7 +3413,7 @@ function SaveBar({
);
}
function SectionPanel({
export function SectionPanel({
title,
subtitle,
actions,
@@ -4411,7 +4420,7 @@ function StoryNpcVisualEditorModal({
);
}
function WorldEditor({
export function WorldEditor({
profile,
onSave,
onClose,
@@ -4502,7 +4511,7 @@ function WorldEditor({
);
}
function CampSceneEditor({
export function CampSceneEditor({
profile,
onSaveProfile,
onClose,
@@ -4523,7 +4532,7 @@ function CampSceneEditor({
);
}
function PlayableNpcEditor({
export function PlayableNpcEditor({
profile,
npc,
mode,
@@ -4782,7 +4791,7 @@ function PlayableNpcEditor({
showClose={false}
/>
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="playable"
onApply={(nextRole) =>
@@ -4827,7 +4836,7 @@ function PlayableNpcEditor({
);
}
function StoryNpcEditor({
export function StoryNpcEditor({
profile,
npc,
mode,
@@ -5078,7 +5087,7 @@ function StoryNpcEditor({
/>
) : null}
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="story"
onApply={(nextRole) =>
@@ -5123,7 +5132,7 @@ function StoryNpcEditor({
);
}
function LandmarkEditor({
export function LandmarkEditor({
profile,
landmark,
mode,
@@ -5908,238 +5917,12 @@ function LandmarkEditor({
);
}
function createPlayableNpc(
profile: CustomWorldProfile,
): CustomWorldPlayableNpc {
const seed = Date.now() + profile.playableNpcs.length;
const template =
ROLE_TEMPLATE_CHARACTERS[
profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
return {
id: createEntryId(
'playable-npc',
`角色-${profile.playableNpcs.length + 1}`,
seed,
),
name: `自定义角色${profile.playableNpcs.length + 1}`,
title: '自定义身份',
role: '世界中的行动者',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 18,
relationshipHooks: ['首次接触', '合作空间'],
relations: [],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
templateCharacterId: template?.id,
};
}
function createStoryNpc(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
'story-npc',
`场景角色-${profile.storyNpcs.length + 1}`,
seed,
),
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
title: '自定义头衔',
role: '自定义身份',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 6,
relationshipHooks: ['合作', '互动'],
relations: [],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
} satisfies CustomWorldNpc;
return npc;
}
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
const seed = Date.now() + profile.landmarks.length;
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
return {
id: createEntryId(
'landmark',
`scene-${profile.landmarks.length + 1}`,
seed,
),
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [
{
targetLandmarkId: previousLandmark.id,
relativePosition: 'south',
summary: `南侧可回到${previousLandmark.name}`,
},
]
: [],
};
}
function CustomWorldEntityEditorModal({
function RpgCreationEntityEditorModal({
profile,
target,
onClose,
onProfileChange,
}: CustomWorldEntityEditorModalProps) {
}: RpgCreationEntityEditorModalProps) {
if (!target) return null;
if (target.kind === 'world') {
@@ -6173,102 +5956,62 @@ function CustomWorldEntityEditorModal({
}
if (target.kind === 'playable') {
if (target.mode === 'create') {
return (
<PlayableNpcEditor
profile={profile}
npc={createPlayableNpc(profile)}
mode="create"
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs: [...profile.playableNpcs, nextNpc],
})
}
onClose={onClose}
/>
);
}
const npc = profile.playableNpcs.find((item) => item.id === target.id);
const npc = resolveEditablePlayableNpc(profile, target);
return npc ? (
<PlayableNpcEditor
profile={profile}
npc={npc}
mode="edit"
mode={target.mode}
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
...profile,
playableNpcs:
target.mode === 'create'
? [...profile.playableNpcs, nextNpc]
: profile.playableNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.kind === 'story') {
if (target.mode === 'create') {
return (
<StoryNpcEditor
profile={profile}
npc={createStoryNpc(profile)}
mode="create"
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs: [...profile.storyNpcs, nextNpc],
})
}
onClose={onClose}
/>
);
}
const npc = profile.storyNpcs.find((item) => item.id === target.id);
const npc = resolveEditableStoryNpc(profile, target);
return npc ? (
<StoryNpcEditor
profile={profile}
npc={npc}
mode="edit"
mode={target.mode}
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs: profile.storyNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
...profile,
storyNpcs:
target.mode === 'create'
? [...profile.storyNpcs, nextNpc]
: profile.storyNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);
const landmark = resolveEditableLandmark(profile, target);
return landmark ? (
<LandmarkEditor
profile={profile}
landmark={landmark}
mode="edit"
mode={target.mode}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
) : null;
}
export { CustomWorldEntityEditorModal };
export default CustomWorldEntityEditorModal;
export { RpgCreationEntityEditorModal };
export default RpgCreationEntityEditorModal;

View File

@@ -0,0 +1,304 @@
import type {
RpgCreationEditorTarget,
RpgCreationEntityEditorModalProps,
} from './RpgCreationEntityEditorModalImpl';
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../../types';
/**
* 工作包 C 第一轮先把编辑器目标分发需要的 profile 变更收口到 mapper。
* 后续继续拆 section 表单时,提交 patch 和字段清洗会逐步下沉到这里。
*/
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'entry';
}
function createEntryId(prefix: string, label: string, seed: number) {
return `${prefix}-${slugify(label || `${prefix}-${seed}`)}-${seed.toString(36)}`;
}
const BACKSTORY_UNLOCK_AFFINITY_EASED = 6;
const BACKSTORY_UNLOCK_AFFINITY_FRIENDLY = 12;
const BACKSTORY_UNLOCK_AFFINITY_TRUSTED = 18;
const BACKSTORY_UNLOCK_AFFINITY_CLOSE = 24;
export function createPlayableNpcDraft(
profile: CustomWorldProfile,
): CustomWorldPlayableNpc {
const seed = Date.now() + profile.playableNpcs.length;
return {
id: createEntryId(
'playable-npc',
`角色-${profile.playableNpcs.length + 1}`,
seed,
),
name: `自定义角色${profile.playableNpcs.length + 1}`,
title: '自定义身份',
role: '世界中的行动者',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 18,
relationshipHooks: ['首次接触', '合作空间'],
relations: [],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
templateCharacterId: profile.playableNpcs[0]?.templateCharacterId,
};
}
export function createStoryNpcDraft(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
return {
id: createEntryId(
'story-npc',
`场景角色-${profile.storyNpcs.length + 1}`,
seed,
),
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
title: '自定义头衔',
role: '自定义身份',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 6,
relationshipHooks: ['合作', '互动'],
relations: [],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
} satisfies CustomWorldNpc;
}
export function createLandmarkDraft(
profile: CustomWorldProfile,
): CustomWorldLandmark {
const seed = Date.now() + profile.landmarks.length;
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
return {
id: createEntryId(
'landmark',
`scene-${profile.landmarks.length + 1}`,
seed,
),
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [
{
targetLandmarkId: previousLandmark.id,
relativePosition: 'south',
summary: `南侧可回到${previousLandmark.name}`,
},
]
: [],
};
}
export function buildEditorTargetRendererParams(
props: RpgCreationEntityEditorModalProps,
) {
const { profile, target, onClose, onProfileChange } = props;
return {
onClose,
onProfileChange,
profile,
target,
};
}
export function resolveEditablePlayableNpc(
profile: CustomWorldProfile,
target: Extract<RpgCreationEditorTarget, { kind: 'playable' }>,
) {
if (target.mode === 'create') {
return createPlayableNpcDraft(profile);
}
return profile.playableNpcs.find((item) => item.id === target.id) ?? null;
}
export function resolveEditableStoryNpc(
profile: CustomWorldProfile,
target: Extract<RpgCreationEditorTarget, { kind: 'story' }>,
) {
if (target.mode === 'create') {
return createStoryNpcDraft(profile);
}
return profile.storyNpcs.find((item) => item.id === target.id) ?? null;
}
export function resolveEditableLandmark(
profile: CustomWorldProfile,
target: Extract<RpgCreationEditorTarget, { kind: 'landmark' }>,
) {
if (target.mode === 'create') {
return createLandmarkDraft(profile);
}
return profile.landmarks.find((item) => item.id === target.id) ?? null;
}

View File

@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldProfile } from '../../types';
type RpgCreationAssetDebugEntry = {
id: string;
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
label: string;
imageSrc: string;
};
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
const RPG_CREATION_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
const RPG_CREATION_ASSET_DEBUG_STORAGE_KEY =
'genarrative.debug.customWorldAssets';
function isPresent<T>(value: T | null): value is T {
return value !== null;
}
export function shouldEnableRpgCreationAssetDebugPanel() {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get(RPG_CREATION_ASSET_DEBUG_QUERY_KEY) === '1') {
return true;
}
return (
window.localStorage.getItem(RPG_CREATION_ASSET_DEBUG_STORAGE_KEY) === '1'
);
}
function collectRpgCreationAssetDebugEntries(
profile: CustomWorldProfile,
): RpgCreationAssetDebugEntry[] {
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(isPresent);
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(isPresent);
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(isPresent);
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(isPresent),
) ?? [];
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
}`,
},
];
}
export function RpgCreationAssetDebugPanel({
profile,
}: {
profile: CustomWorldProfile;
}) {
const assetDebugEntries = useMemo(
() => collectRpgCreationAssetDebugEntries(profile),
[profile],
);
const assetDebugSummary = useMemo(
() => resolveAssetDebugSummary(profile),
[profile],
);
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record<string, AssetDebugLoadStatus>
>({});
useEffect(() => {
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());
};
}, [assetDebugEntries]);
return (
<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>
);
}
export default RpgCreationAssetDebugPanel;

View File

@@ -0,0 +1,97 @@
import type { ReactNode } from 'react';
import type { CustomWorldProfile } from '../../types';
function SmallButton({
children,
disabled = false,
onClick,
tone = 'default',
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
tone?: 'default' | 'sky';
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`${
tone === 'sky'
? 'platform-button platform-button--primary'
: 'platform-button platform-button--ghost'
} min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
);
}
interface RpgCreationResultActionBarProps {
editActionLabel: string;
enterWorldActionLabel: string;
isGenerating: boolean;
onContinueExpand?: () => void;
onEditSetting?: () => void;
onEnterWorld?: () => void;
onRegenerate?: () => void;
profile: CustomWorldProfile;
regenerateActionLabel: string;
publishReady: boolean;
}
export function RpgCreationResultActionBar({
editActionLabel,
enterWorldActionLabel,
isGenerating,
onContinueExpand,
onEditSetting,
onEnterWorld,
onRegenerate,
profile,
regenerateActionLabel,
publishReady,
}: RpgCreationResultActionBarProps) {
return (
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
<div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
</div>
) : null}
<div className="flex items-center justify-end gap-3">
{onEditSetting ? (
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
) : null}
{onRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky">
{regenerateActionLabel}
</SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton
onClick={onContinueExpand}
tone="sky"
disabled={isGenerating}
>
</SmallButton>
) : null}
{onEnterWorld ? (
<button
type="button"
onClick={onEnterWorld}
disabled={isGenerating || !publishReady}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
{enterWorldActionLabel}
</button>
) : null}
</div>
</div>
);
}
export default RpgCreationResultActionBar;

View File

@@ -0,0 +1,59 @@
interface RpgCreationResultHeaderProps {
autoSaveState: 'idle' | 'saving' | 'saved' | 'error';
backLabel: string;
isGenerating: boolean;
onBack: () => void;
}
function renderAutoSaveBadge(
autoSaveState: RpgCreationResultHeaderProps['autoSaveState'],
) {
if (autoSaveState === 'saved') {
return (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
);
}
if (autoSaveState === 'saving') {
return (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
);
}
if (autoSaveState === 'error') {
return (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
);
}
return null;
}
export function RpgCreationResultHeader({
autoSaveState,
backLabel,
isGenerating,
onBack,
}: RpgCreationResultHeaderProps) {
return (
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
>
{backLabel}
</button>
{renderAutoSaveBadge(autoSaveState)}
</div>
);
}
export default RpgCreationResultHeader;

View File

@@ -1,17 +1,17 @@
import type { ComponentProps } from 'react';
import { CustomWorldResultView } from '../CustomWorldResultView';
import { RpgCreationResultView as RpgCreationResultViewImpl } from './RpgCreationResultViewImpl';
/**
* 工作包 A 先提供 RPG 创作结果页的新命名 façade
* 当前结果页行为仍由旧组件承载,后续工作包 C 会在此目录内继续拆分 header、action bar 和 section
* 工作包 C 完成后,结果页入口统一桥接 RPG 创作目录下的真实实现
* 旧 `CustomWorldResultView.tsx` 兼容入口已经删除,后续结果页细化继续在该目录内部推进
*/
export type RpgCreationResultViewProps = ComponentProps<
typeof CustomWorldResultView
typeof RpgCreationResultViewImpl
>;
export function RpgCreationResultView(props: RpgCreationResultViewProps) {
return <CustomWorldResultView {...props} />;
return <RpgCreationResultViewImpl {...props} />;
}
export default RpgCreationResultView;

View File

@@ -0,0 +1,229 @@
import { useMemo, useState } from 'react';
import type { Character, CustomWorldProfile } from '../../types';
import {
CustomWorldEntityCatalog,
type ResultTab,
} from '../CustomWorldEntityCatalog';
import RpgCreationEntityEditorModal from '../rpg-creation-editor/RpgCreationEntityEditorModal';
import RpgCreationAssetDebugPanel, {
shouldEnableRpgCreationAssetDebugPanel,
} from './RpgCreationAssetDebugPanel';
import RpgCreationResultActionBar from './RpgCreationResultActionBar';
import RpgCreationResultHeader from './RpgCreationResultHeader';
import { useRpgCreationResultActions } from './useRpgCreationResultActions';
export interface RpgCreationResultViewProps {
profile: CustomWorldProfile;
previewCharacters: Character[];
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onEnterWorld?: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
backLabel?: string;
editActionLabel?: string;
regenerateActionLabel?: string;
enterWorldActionLabel?: string;
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
compactAgentResultMode?: boolean;
publishReady?: boolean;
publishBlockers?: string[];
qualityFindings?: Array<{
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}>;
previewSourceLabel?: string | null;
}
export function RpgCreationResultView({
profile,
previewCharacters,
isGenerating,
progress,
progressLabel,
error,
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onEnterWorld,
onProfileChange,
readOnly = false,
backLabel = '返回',
editActionLabel = '修改设定',
regenerateActionLabel = '重新生成',
enterWorldActionLabel = '进入世界',
autoSaveState = 'idle',
compactAgentResultMode = false,
publishReady = true,
publishBlockers = [],
qualityFindings = [],
previewSourceLabel = null,
}: RpgCreationResultViewProps) {
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const assetDebugEnabled = useMemo(
() => shouldEnableRpgCreationAssetDebugPanel(),
[],
);
const {
closeEditorTarget,
createLabel,
createTarget,
editorTarget,
handleDeleteLandmarks,
handleDeleteStoryNpcs,
handleGenerateEntity,
handleRegenerate,
localGenerationError,
pendingGeneratedEntity,
recentGeneratedIds,
setEditorTarget,
} = useRpgCreationResultActions({
activeTab,
isGenerating,
onProfileChange,
profile,
readOnly,
triggerRegenerate,
});
return (
<div className="flex h-full min-h-0 flex-col">
<RpgCreationResultHeader
autoSaveState={autoSaveState}
backLabel={backLabel}
isGenerating={isGenerating}
onBack={onBack}
/>
<div className="min-h-0 flex-1 overflow-hidden">
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={previewCharacters}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={
readOnly || compactAgentResultMode ? undefined : createLabel
}
onCreateAction={
readOnly || compactAgentResultMode || !createTarget
? undefined
: () => {
if (activeTab === 'playable') {
void handleGenerateEntity('playable');
return;
}
if (activeTab === 'story') {
void handleGenerateEntity('story');
return;
}
if (activeTab === 'landmarks') {
void handleGenerateEntity('landmark');
return;
}
setEditorTarget(createTarget);
}
}
createActionDisabled={Boolean(
isGenerating || pendingGeneratedEntity,
)}
pendingGeneratedEntity={pendingGeneratedEntity}
recentGeneratedIds={recentGeneratedIds}
readOnly={readOnly}
/>
</div>
{isGenerating && (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{progressLabel}
</div>
<div className="text-xs text-[var(--platform-text-base)]">
{Math.round(progress)}%
</div>
</div>
<div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
<div
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
</div>
)}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{!error && compactAgentResultMode && previewSourceLabel ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{previewSourceLabel}
</div>
) : null}
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
{publishReady
? '当前世界已满足发布门槛。'
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
<div className="mt-2 space-y-1">
{publishBlockers.slice(0, 4).map((entry, index) => (
<div key={`publish-blocker-${index}-${entry}`}>
{index + 1}. {entry}
</div>
))}
</div>
</div>
) : null}
{!error &&
compactAgentResultMode &&
publishBlockers.length <= 0 &&
qualityFindings.some((entry) => entry.severity === 'warning') ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{qualityFindings.filter((entry) => entry.severity === 'warning').length} warning
</div>
) : null}
{!error && localGenerationError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{localGenerationError}
</div>
) : null}
{assetDebugEnabled ? <RpgCreationAssetDebugPanel profile={profile} /> : null}
<RpgCreationResultActionBar
editActionLabel={editActionLabel}
enterWorldActionLabel={enterWorldActionLabel}
isGenerating={isGenerating}
onContinueExpand={onContinueExpand}
onEditSetting={onEditSetting}
onEnterWorld={onEnterWorld}
onRegenerate={triggerRegenerate ? handleRegenerate : undefined}
profile={profile}
regenerateActionLabel={regenerateActionLabel}
publishReady={publishReady}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={editorTarget}
onClose={closeEditorTarget}
onProfileChange={onProfileChange}
/>
</div>
);
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../../types';
import type { ResultTab } from '../CustomWorldEntityCatalog';
import type { RpgCreationEditorTarget } from '../rpg-creation-editor/RpgCreationEntityEditorModal';
export type EntityGenerationKind = 'playable' | 'story' | 'landmark';
export type PendingGeneratedEntity = {
id: string;
kind: EntityGenerationKind;
title: string;
progress: number;
phaseLabel: string;
};
export type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
function getCreateTargetByTab(
activeTab: ResultTab,
): RpgCreationEditorTarget | null {
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
return null;
}
function getCreateLabelByTab(activeTab: ResultTab) {
if (activeTab === 'playable') return '新增可扮演角色';
if (activeTab === 'story') return '新增场景角色';
if (activeTab === 'landmarks') return '新增场景';
return '';
}
function createPendingGeneratedEntity(
kind: EntityGenerationKind,
): PendingGeneratedEntity {
return {
id: `pending-${kind}-${Date.now()}`,
kind,
title:
kind === 'playable'
? '新可扮演角色'
: kind === 'story'
? '新场景角色'
: '新场景',
progress: 8,
phaseLabel: '正在整理世界上下文',
};
}
function resolvePendingPhaseLabel(
kind: EntityGenerationKind,
progress: number,
) {
if (progress < 28) {
return '正在整理世界上下文';
}
if (progress < 72) {
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
}
return '正在回写结果';
}
function prependPlayableNpc(
profile: CustomWorldProfile,
npc: CustomWorldPlayableNpc,
) {
return {
...profile,
playableNpcs: [npc, ...profile.playableNpcs],
} satisfies CustomWorldProfile;
}
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
return {
...profile,
storyNpcs: [npc, ...profile.storyNpcs],
} satisfies CustomWorldProfile;
}
function prependLandmark(
profile: CustomWorldProfile,
landmark: CustomWorldLandmark,
) {
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: [landmark, ...profile.landmarks],
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeStoryNpcsFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
return {
...profile,
storyNpcs: nextStoryNpcs,
landmarks: normalizeCustomWorldLandmarks({
landmarks: profile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
})),
storyNpcs: nextStoryNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeLandmarksFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextLandmarks = profile.landmarks.filter(
(landmark) => !idSet.has(landmark.id),
);
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: nextLandmarks.map((landmark) => ({
...landmark,
connections: landmark.connections.filter(
(connection) => !idSet.has(connection.targetLandmarkId),
),
})),
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
export function useRpgCreationResultActions(params: {
activeTab: ResultTab;
isGenerating: boolean;
onProfileChange: (profile: CustomWorldProfile) => void;
profile: CustomWorldProfile;
readOnly: boolean;
triggerRegenerate?: () => void;
}) {
const {
activeTab,
isGenerating,
onProfileChange,
profile,
readOnly,
triggerRegenerate,
} = params;
const [editorTarget, setEditorTarget] =
useState<RpgCreationEditorTarget | null>(null);
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
useState<PendingGeneratedEntity | null>(null);
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
{
playable: [],
story: [],
landmark: [],
},
);
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
null,
);
const pendingProgressTimerRef = useRef<number | null>(null);
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
[activeTab],
);
const createLabel = useMemo(
() => getCreateLabelByTab(activeTab),
[activeTab],
);
const stopPendingProgressTimer = () => {
if (pendingProgressTimerRef.current !== null) {
window.clearInterval(pendingProgressTimerRef.current);
pendingProgressTimerRef.current = null;
}
};
useEffect(() => () => stopPendingProgressTimer(), []);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
pendingProgressTimerRef.current = window.setInterval(() => {
setPendingGeneratedEntity((current) => {
if (!current || current.kind !== kind) {
return current;
}
const nextProgress = Math.min(
current.progress + (current.progress < 56 ? 11 : 5),
88,
);
return {
...current,
progress: nextProgress,
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
};
});
}, 520);
};
const finishPendingProgress = () => {
stopPendingProgressTimer();
setPendingGeneratedEntity(null);
};
const markGeneratedAsRecent = (
kind: EntityGenerationKind,
generatedId: string,
) => {
setRecentGeneratedIds((current) => ({
...current,
[kind]: [
generatedId,
...current[kind].filter((id) => id !== generatedId),
].slice(0, 6),
}));
};
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
if (readOnly || isGenerating || pendingGeneratedEntity) {
return;
}
setLocalGenerationError(null);
startPendingProgress(kind);
try {
if (kind === 'playable') {
const nextNpc = await rpgCreationAssetClient.generatePlayableNpc({
profile,
});
onProfileChange(prependPlayableNpc(profile, nextNpc));
markGeneratedAsRecent('playable', nextNpc.id);
} else if (kind === 'story') {
const nextNpc = await rpgCreationAssetClient.generateStoryNpc({
profile,
});
onProfileChange(prependStoryNpc(profile, nextNpc));
markGeneratedAsRecent('story', nextNpc.id);
} else {
const nextLandmark = await rpgCreationAssetClient.generateLandmark({
profile,
});
onProfileChange(prependLandmark(profile, nextLandmark));
markGeneratedAsRecent('landmark', nextLandmark.id);
}
} catch (generationError) {
setLocalGenerationError(
generationError instanceof Error
? generationError.message
: '生成失败,请稍后重试。',
);
} finally {
finishPendingProgress();
}
};
const handleRegenerate = () => {
if (isGenerating || !triggerRegenerate) {
return;
}
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
);
if (!confirmed) {
return;
}
triggerRegenerate();
};
const handleDeleteStoryNpcs = (ids: string[]) => {
if (ids.length === 0) {
return;
}
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
};
const handleDeleteLandmarks = (ids: string[]) => {
if (ids.length === 0) {
return;
}
onProfileChange(removeLandmarksFromProfile(profile, ids));
};
return {
createLabel,
createTarget,
editorTarget,
handleDeleteLandmarks,
handleDeleteStoryNpcs,
handleGenerateEntity,
handleRegenerate,
localGenerationError,
pendingGeneratedEntity,
recentGeneratedIds,
setEditorTarget,
closeEditorTarget: () => setEditorTarget(null),
};
}

View File

@@ -1,10 +1,16 @@
export function PlatformBrandLogo({
className = '',
decorative = false,
}: {
export interface RpgEntryBrandLogoProps {
className?: string;
decorative?: boolean;
}) {
}
/**
* RPG
* `rpg-entry`
*/
export function RpgEntryBrandLogo({
className = '',
decorative = false,
}: RpgEntryBrandLogoProps) {
return (
<span
className={`platform-brand-logo ${className}`.trim()}

View File

@@ -12,7 +12,7 @@ import {
type CustomWorldProfile,
WorldType,
} from '../../types';
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
vi.mock('../../data/characterPresets', () => ({
ROLE_TEMPLATE_CHARACTERS: [],
@@ -115,7 +115,7 @@ test('custom world character selection stays stable when character ids are empty
});
render(
<CharacterSelectionFlow
<RpgEntryCharacterSelectView
worldType={WorldType.CUSTOM}
customWorldProfile={{
attributeSchema: {

View File

@@ -1,4 +1,4 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildCharacterAttributeProfile,
@@ -11,11 +11,16 @@ import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
} from '../../data/characterPresets';
import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types';
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {CharacterAnimator} from '../CharacterAnimator';
import {CharacterDetailModal} from '../CharacterDetailModal';
import {CharacterDraftModal} from '../SelectionCustomizationModals';
import {
AnimationState,
type Character,
type CustomWorldProfile,
WorldType,
} from '../../types';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { CharacterAnimator } from '../CharacterAnimator';
import { CharacterDetailModal } from '../CharacterDetailModal';
import { CharacterDraftModal } from '../SelectionCustomizationModals';
type CharacterSelectionDraft = {
name: string;
@@ -24,19 +29,47 @@ type CharacterSelectionDraft = {
type CarouselOrientation = 'horizontal' | 'vertical';
type CharacterSelectionFlowProps = {
export type RpgEntryCharacterSelectViewProps = {
worldType: WorldType;
customWorldProfile: CustomWorldProfile | null;
onBack: () => void;
onConfirm: (character: Character) => void;
};
const CHARACTER_DISPLAY: Record<string, {name: string; title: string; role: string; tags: string[]}> = {
'sword-princess': {name: '剑姬', title: '皇家之刃', role: '先锋', tags: ['剑术', '压制', '突进']},
'archer-hero': {name: '弓手英雄', title: '风之射手', role: '远程', tags: ['射程', '齐射', '风筝']},
'girl-hero': {name: '双刃刺客', title: '暗影之牙', role: '刺客', tags: ['连击', '冲锋', '机动']},
'punch-hero': {name: '战拳', title: '近战大师', role: '战士', tags: ['爆发', '格斗', '仇恨']},
'fighter-4': {name: '装甲长矛手', title: '重装先锋', role: '前线', tags: ['守护', '稳定', '突破']},
const CHARACTER_DISPLAY: Record<
string,
{ name: string; title: string; role: string; tags: string[] }
> = {
'sword-princess': {
name: '剑姬',
title: '皇家之刃',
role: '先锋',
tags: ['剑术', '压制', '突进'],
},
'archer-hero': {
name: '弓手英雄',
title: '风之射手',
role: '远程',
tags: ['射程', '齐射', '风筝'],
},
'girl-hero': {
name: '双刃刺客',
title: '暗影之牙',
role: '刺客',
tags: ['连击', '冲锋', '机动'],
},
'punch-hero': {
name: '战拳',
title: '近战大师',
role: '战士',
tags: ['爆发', '格斗', '仇恨'],
},
'fighter-4': {
name: '装甲长矛手',
title: '重装先锋',
role: '前线',
tags: ['守护', '稳定', '突破'],
},
};
function getGenderLabel(gender: Character['gender']) {
@@ -168,12 +201,12 @@ function getCharacterCardStyle(index: number, progress: number) {
};
}
export function CharacterSelectionFlow({
export function RpgEntryCharacterSelectView({
worldType,
customWorldProfile,
onBack,
onConfirm,
}: CharacterSelectionFlowProps) {
}: RpgEntryCharacterSelectViewProps) {
const selectionCharacters = useMemo(
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile],
@@ -467,3 +500,5 @@ export function CharacterSelectionFlow({
</>
);
}
export const CharacterSelectionFlow = RpgEntryCharacterSelectView;

View File

@@ -1,12 +1,12 @@
import { ArrowRight, X } from 'lucide-react';
type PlatformCreationTypeModalProps = {
export interface RpgEntryCreationTypeModalProps {
isOpen: boolean;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSelectRpg: () => void;
};
}
type CreationGameTypeCard = {
id: 'rpg' | 'airp' | 'visual-novel';
@@ -89,13 +89,17 @@ function CreationTypeCard(props: {
);
}
export function PlatformCreationTypeModal({
/**
* RPG
* `PlatformCreationTypeModal`
*/
export function RpgEntryCreationTypeModal({
isOpen,
isBusy,
error,
onClose,
onSelectRpg,
}: PlatformCreationTypeModalProps) {
}: RpgEntryCreationTypeModalProps) {
if (!isOpen) {
return null;
}

View File

@@ -8,36 +8,38 @@ import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
createCustomWorldAgentSession,
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
listCustomWorldWorks,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationOperation,
getRpgCreationSession,
listRpgCreationWorks,
streamRpgCreationMessage,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard,
listCustomWorldGallery,
listCustomWorldLibrary,
listProfileBrowseHistory,
listProfileSaveArchives,
resumeProfileSaveArchive,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
} from '../../services/storageService';
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail,
getRpgProfileDashboard as getProfileDashboard,
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory as listProfileBrowseHistory,
listRpgProfileSaveArchives as listProfileSaveArchives,
publishRpgEntryWorldProfile,
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
unpublishRpgEntryWorldProfile,
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
} from '../../services/rpg-entry';
import type { GameState } from '../../types';
import {
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
import {
PreGameSelectionFlow,
RpgEntryFlowShell,
type SelectionStage,
} from './PreGameSelectionFlow';
} from './RpgEntryFlowShell';
async function clickFirstButtonByName(
user: ReturnType<typeof userEvent.setup>,
@@ -72,31 +74,30 @@ async function openNewRpgCreation(
await user.click(screen.getByRole('button', { name: / RPG/u }));
}
vi.mock('../../services/aiService', () => ({
createCustomWorldAgentSession: vi.fn(),
executeCustomWorldAgentAction: vi.fn(),
generateCustomWorldProfile: vi.fn(),
getCustomWorldAgentOperation: vi.fn(),
getCustomWorldAgentSession: vi.fn(),
listCustomWorldWorks: vi.fn(),
streamCustomWorldAgentMessage: vi.fn(),
vi.mock('../../services/rpg-creation', () => ({
createRpgCreationSession: vi.fn(),
executeRpgCreationAction: vi.fn(),
getRpgCreationOperation: vi.fn(),
getRpgCreationSession: vi.fn(),
listRpgCreationWorks: vi.fn(),
streamRpgCreationMessage: vi.fn(),
upsertRpgWorldProfile: vi.fn(),
}));
vi.mock('../../services/storageService', () => ({
clearProfileBrowseHistory: vi.fn(),
deleteCustomWorldProfile: vi.fn(),
getCustomWorldGalleryDetail: vi.fn(),
getProfileDashboard: vi.fn(),
listCustomWorldGallery: vi.fn(),
listCustomWorldLibrary: vi.fn(),
listProfileBrowseHistory: vi.fn(),
listProfileSaveArchives: vi.fn(),
publishCustomWorldProfile: vi.fn(),
resumeProfileSaveArchive: vi.fn(),
syncProfileBrowseHistory: vi.fn(),
unpublishCustomWorldProfile: vi.fn(),
upsertProfileBrowseHistory: vi.fn(),
upsertCustomWorldProfile: vi.fn(),
vi.mock('../../services/rpg-entry', () => ({
clearRpgProfileBrowseHistory: vi.fn(),
deleteRpgEntryWorldProfile: vi.fn(),
getRpgEntryWorldGalleryDetail: vi.fn(),
getRpgProfileDashboard: vi.fn(),
listRpgEntryWorldGallery: vi.fn(),
listRpgEntryWorldLibrary: vi.fn(),
listRpgProfileBrowseHistory: vi.fn(),
listRpgProfileSaveArchives: vi.fn(),
publishRpgEntryWorldProfile: vi.fn(),
resumeRpgProfileSaveArchive: vi.fn(),
syncRpgProfileBrowseHistory: vi.fn(),
unpublishRpgEntryWorldProfile: vi.fn(),
upsertRpgProfileBrowseHistory: vi.fn(),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
@@ -312,6 +313,72 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
warningCount: 0,
},
],
resultPreview: {
source: 'session_preview',
preview: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
description: '最熟悉旧航路的人。',
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
personality: '表面沉稳,心里一直在算退路。',
motivation: '想赶在守灯会封航前查清真相。',
combatStyle: '借地形和潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: ['旧友', '沉船旧案'],
tags: ['潮路', '引路'],
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
description: '夜里巡灯与封锁禁航区的人。',
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: ['禁航记录', '灯塔值夜'],
tags: ['守灯会', '灯塔'],
},
],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '旧灯塔是整片群岛最先看见异动的地方。',
dangerLevel: 'high',
sceneNpcIds: ['story-1'],
connections: [],
},
],
generationMode: 'full',
generationStatus: 'complete',
sessionId: 'custom-world-agent-session-1',
},
generatedAt: '2026-04-14T12:00:00.000Z',
qualityFindings: [],
blockers: [],
publishReady: true,
canEnterWorld: false,
},
};
type TestAuthValue = {
@@ -362,7 +429,7 @@ function TestWrapper({
useState<SelectionStage>('platform');
const content = (
<PreGameSelectionFlow
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
@@ -398,8 +465,8 @@ beforeEach(() => {
playedWorldCount: 0,
updatedAt: '2026-04-16T12:00:00.000Z',
});
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([]);
vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([]);
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
@@ -424,8 +491,8 @@ beforeEach(() => {
});
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
profileId: 'agent-draft-custom-world-agent-session-1',
@@ -447,11 +514,11 @@ beforeEach(() => {
},
entries: [],
});
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(listCustomWorldWorks).mockResolvedValue([]);
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
@@ -462,7 +529,7 @@ beforeEach(() => {
error: null,
},
});
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'running',
@@ -471,8 +538,8 @@ beforeEach(() => {
progress: 38,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
vi.mocked(streamCustomWorldAgentMessage).mockResolvedValue(mockSession);
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
@@ -499,7 +566,7 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
await user.click(screen.getByRole('button', { name: / RPG/u }));
await waitFor(() => {
expect(createCustomWorldAgentSession).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
});
expect(
@@ -510,7 +577,7 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
test('create tab opens compiled agent draft in result refinement page', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
@@ -536,7 +603,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
canEnterWorld: false,
},
]);
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
vi.mocked(getRpgCreationSession).mockResolvedValue(
compiledAgentDraftSession,
);
@@ -561,7 +628,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
@@ -606,7 +673,7 @@ test('clicking a public work while logged out routes through requireAuth', async
const user = userEvent.setup();
const requireAuth = vi.fn();
vi.mocked(listCustomWorldGallery).mockResolvedValue([
vi.mocked(listRpgEntryWorldGallery).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-public-1',
@@ -640,7 +707,7 @@ test('clicking a public work while logged out routes through requireAuth', async
await user.click(workCards[0]!);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled();
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
@@ -659,7 +726,7 @@ test('selecting RPG creation while logged out routes through requireAuth', async
await openNewRpgCreation(user);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
});
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
@@ -686,7 +753,7 @@ test('restoring an agent workspace while logged out opens login modal before loa
});
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
expect(getCustomWorldAgentSession).not.toHaveBeenCalled();
expect(getRpgCreationSession).not.toHaveBeenCalled();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
@@ -703,7 +770,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
await waitFor(() => {
expect(executeCustomWorldAgentAction).toHaveBeenCalledWith(
expect(executeRpgCreationAction).toHaveBeenCalledWith(
'custom-world-agent-session-1',
{
action: 'draft_foundation',
@@ -724,7 +791,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
@@ -733,7 +800,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
progress: 100,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
vi.mocked(getRpgCreationSession).mockResolvedValue(
compiledAgentDraftSession,
);
@@ -745,7 +812,9 @@ test('existing draft sessions open result page refinement instead of agent dialo
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
},
{ timeout: 2500 },
);
@@ -762,10 +831,167 @@ test('existing draft sessions open result page refinement instead of agent dialo
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
});
test('agent result view shows publish blockers and disables publish-enter action when preview gate is not ready', async () => {
const user = userEvent.setup();
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
...compiledAgentDraftSession,
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
blockers: [
{
id: 'publish-role-assets-incomplete',
code: 'publish_role_assets_incomplete',
message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
},
],
},
});
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);
expect(
await screen.findByText(/ 1 /u),
).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: //u,
});
expect((actionButton as HTMLButtonElement).disabled).toBe(true);
});
test('agent draft result publishes before entering world and uses published preview profile', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const publishReadyDraftSession = {
...compiledAgentDraftSession,
stage: 'ready_to_publish' as const,
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: true,
canEnterWorld: false,
blockers: [],
},
} satisfies CustomWorldAgentSessionSnapshot;
const publishedSession = {
...publishReadyDraftSession,
stage: 'published' as const,
resultPreview: {
...publishReadyDraftSession.resultPreview!,
publishReady: true,
canEnterWorld: true,
blockers: [],
preview: {
...publishReadyDraftSession.resultPreview!.preview,
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛·已发布',
summary: '发布完成后应直接使用已发布预览进入世界。',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
let hasPublishedWorld = false;
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
operationId: 'operation-publish-world-1',
type: 'publish_world',
status: 'completed',
phaseLabel: '世界已发布',
phaseDetail: '正式世界档案已写入作品库。',
progress: 100,
error: null,
});
vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => {
if (payload.action === 'publish_world') {
hasPublishedWorld = true;
}
return {
operation: {
operationId: 'operation-publish-world-1',
type: 'publish_world',
status: 'queued',
phaseLabel: '执行发布校验',
phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。',
progress: 28,
error: null,
},
};
});
vi.mocked(getRpgCreationSession).mockImplementation(async () =>
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
);
function PublishFlowWrapper() {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</AuthUiContext.Provider>
);
}
render(<PublishFlowWrapper />);
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
});
await user.click(actionButton);
await waitFor(() => {
expect(executeRpgCreationAction).toHaveBeenCalledWith(
'custom-world-agent-session-1',
expect.objectContaining({
action: 'publish_world',
}),
);
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
name: '潮雾列岛·已发布',
summary: '发布完成后应直接使用已发布预览进入世界。',
}),
);
});
});
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
@@ -776,7 +1002,7 @@ test('agent draft result back button returns to creation hub without redundant s
error: null,
},
});
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
status: 'completed',
@@ -893,8 +1119,35 @@ test('agent draft result back button returns to creation hub without redundant s
warningCount: 0,
},
],
resultPreview: {
source: 'session_preview' as const,
preview: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·同步后',
subtitle: '旧灯塔与失控航路',
summary: '同步后的结果页快照已经回写到 session。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
sessionId: 'custom-world-agent-session-1',
},
generatedAt: '2026-04-20T12:00:00.000Z',
qualityFindings: [],
blockers: [],
publishReady: false,
canEnterWorld: false,
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession);
vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession);
render(<TestWrapper withAuth />);
@@ -915,7 +1168,7 @@ test('agent draft result back button returns to creation hub without redundant s
});
expect(
vi.mocked(executeCustomWorldAgentAction).mock.calls.some(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
@@ -927,7 +1180,7 @@ test('agent draft result back button returns to creation hub without redundant s
test('agent draft result auto-save persists the latest profile rebuilt from synced session', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-sync-result-profile-2',
type: 'sync_result_profile',
@@ -938,7 +1191,7 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
error: null,
},
});
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-2',
type: 'sync_result_profile',
status: 'completed',
@@ -1016,8 +1269,35 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
warningCount: 0,
},
],
resultPreview: {
source: 'session_preview' as const,
preview: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·session最新版',
subtitle: '旧灯塔与失控航路',
summary: '作品库应该保存这份同步后的最新快照。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
sessionId: 'custom-world-agent-session-1',
},
generatedAt: '2026-04-20T12:00:00.000Z',
qualityFindings: [],
blockers: [],
publishReady: false,
canEnterWorld: false,
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession);
vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession);
render(<TestWrapper withAuth />);
@@ -1032,16 +1312,74 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
);
await waitFor(() => {
expect(upsertCustomWorldProfile).toHaveBeenCalled();
expect(upsertRpgWorldProfile).toHaveBeenCalled();
});
const latestSavedProfile = vi.mocked(upsertCustomWorldProfile).mock.calls.at(-1)?.[0];
const latestSavedProfile = vi.mocked(upsertRpgWorldProfile).mock.calls.at(-1)?.[0];
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
expect(latestSavedProfile?.summary).toBe(
'作品库应该保存这份同步后的最新快照。',
);
});
test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => {
const user = userEvent.setup();
const previewOnlySession = {
...compiledAgentDraftSession,
draftProfile: {
...compiledAgentDraftSession.draftProfile,
playableNpcs: [],
storyNpcs: [],
landmarks: [],
},
resultPreview: {
source: 'session_preview' as const,
preview: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·服务端预览',
subtitle: '结果页改为优先消费 session.resultPreview',
summary: '即使 draft 中没有 legacyResultProfile也应该正常打开结果页。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
sessionId: 'custom-world-agent-session-1',
},
generatedAt: '2026-04-20T12:00:00.000Z',
qualityFindings: [],
blockers: [],
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(previewOnlySession);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(
screen.getByText('潮雾列岛·服务端预览'),
).toBeTruthy();
expect(
screen.getByText('结果页改为优先消费 session.resultPreview'),
).toBeTruthy();
},
{ timeout: 2500 },
);
});
test('authenticated users with save archives default into the saves tab', async () => {
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
@@ -1172,13 +1510,13 @@ test('owned world detail can delete a work and return to the create tab list', a
landmarkCount: 0,
};
vi.mocked(listCustomWorldWorks)
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([publishedWork])
.mockResolvedValue([]);
vi.mocked(listCustomWorldLibrary)
vi.mocked(listRpgEntryWorldLibrary)
.mockResolvedValueOnce([publishedLibraryEntry])
.mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
render(<TestWrapper withAuth />);
@@ -1187,7 +1525,7 @@ test('owned world detail can delete a work and return to the create tab list', a
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
expect(deleteCustomWorldProfile).toHaveBeenCalledWith('world-delete-1');
expect(deleteRpgEntryWorldProfile).toHaveBeenCalledWith('world-delete-1');
});
await waitFor(() => {
@@ -1201,7 +1539,7 @@ test('owned world detail can delete a work and return to the create tab list', a
test('creation hub published work enters existing detail view', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'published:world-public-1',
sourceType: 'published_profile',
@@ -1227,7 +1565,7 @@ test('creation hub published work enters existing detail view', async () => {
canEnterWorld: true,
},
]);
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-public-1',

View File

@@ -0,0 +1,20 @@
import { RpgEntryFlowShellImpl } from './RpgEntryFlowShellImpl';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import type { SelectionStage } from './rpgEntryTypes';
export type { RpgEntryFlowShellProps, SelectionStage };
/**
* RPG 入口域真实壳层入口。
* 入口主链已收口到 `rpg-entry` 命名根,不再保留旧入口脚本。
*/
export function RpgEntryFlowShell(props: RpgEntryFlowShellProps) {
return <RpgEntryFlowShellImpl {...props} />;
}
/**
* 兼容创作链已经接入的旧组件命名,避免本轮迁移扩大影响面。
*/
export const RpgCreationShell = RpgEntryFlowShell;
export default RpgEntryFlowShell;

View File

@@ -0,0 +1,718 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState';
import { getRpgProfileDashboard } from '../../services/rpg-entry';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { RpgEntryCreationTypeModal } from './RpgEntryCreationTypeModal';
import { RpgEntryHomeView } from './RpgEntryHomeView';
import {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import { RpgEntryWorldDetailView } from './RpgEntryWorldDetailView';
import { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from './useRpgCreationSessionController';
import { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
import { useRpgEntryNavigation } from './useRpgEntryNavigation';
const CustomWorldGenerationView = lazy(async () => {
const module = await import('../CustomWorldGenerationView');
return {
default: module.CustomWorldGenerationView,
};
});
const RpgCreationResultView = lazy(async () => {
const module = await import('../rpg-creation-result/RpgCreationResultView');
return {
default: module.RpgCreationResultView,
};
});
const CustomWorldAgentWorkspace = lazy(async () => {
const module = await import(
'../custom-world-agent/CustomWorldAgentWorkspace'
);
return {
default: module.CustomWorldAgentWorkspace,
};
});
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-[var(--platform-text-base)]">
{label}
</div>
</div>
);
}
export function RpgEntryFlowShellImpl({
selectionStage,
setSelectionStage,
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
}: RpgEntryFlowShellProps) {
const authUi = useAuthUi();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(null);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId,
);
const platformBootstrap = useRpgEntryBootstrap({
user: authUi?.user,
getProfileDashboard: getRpgProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
});
const entryNavigation = useRpgEntryNavigation({
setSelectionStage,
setSelectedDetailEntry,
});
const enterCreateTab = useCallback(() => {
platformBootstrap.setPlatformTab('create');
}, [platformBootstrap]);
const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal,
selectionStage,
setSelectionStage,
enterCreateTab,
onSessionOpened: () => {
setShowCreationTypeModal(false);
},
});
useRpgCreationAgentOperationPolling({
activeAgentSessionId: sessionController.activeAgentSessionId,
activeAgentOperationId: sessionController.activeAgentOperationId,
userId: authUi?.user?.id,
setAgentOperation: sessionController.setAgentOperation,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
});
const autosaveCoordinator = useRpgCreationResultAutosave({
selectionStage,
activeAgentSessionId: sessionController.activeAgentSessionId,
agentSession: sessionController.agentSession,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
isAgentDraftResultView: sessionController.isAgentDraftResultView,
userId: authUi?.user?.id,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setAgentOperation: sessionController.setAgentOperation,
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
setSelectedDetailEntry,
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
});
const detailNavigation = useRpgEntryLibraryDetail({
userId: authUi?.user?.id,
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: platformBootstrap.savedCustomWorldEntries,
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setCustomWorldError: sessionController.setCustomWorldError,
setCustomWorldAutoSaveError: autosaveCoordinator.setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState: autosaveCoordinator.setCustomWorldAutoSaveState,
setCustomWorldGenerationViewSource:
sessionController.setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource:
sessionController.setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate: enterCreateTab,
setPlatformError: platformBootstrap.setPlatformError,
appendBrowseHistoryEntry: platformBootstrap.appendBrowseHistoryEntry,
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
refreshPublishedGallery: platformBootstrap.refreshPublishedGallery,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
suppressAgentDraftResultAutoOpen:
sessionController.suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression:
sessionController.releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle: autosaveCoordinator.resetAutoSaveTrackingToIdle,
markAutoSavedProfile: autosaveCoordinator.markAutoSavedProfile,
});
const enterWorldCoordinator = useRpgCreationEnterWorld({
isAgentDraftResultView: sessionController.isAgentDraftResultView,
activeAgentSessionId: sessionController.activeAgentSessionId,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
agentSessionProfile: sessionController.agentDraftResultProfile,
agentSession: sessionController.agentSession,
handleCustomWorldSelect,
executePublishWorld: () =>
autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
}),
syncAgentDraftResultProfile: autosaveCoordinator.syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});
const previewCustomWorldCharacters = useMemo(
() =>
sessionController.generatedCustomWorldProfile
? buildCustomWorldPlayableCharacters(
sessionController.generatedCustomWorldProfile,
)
: [],
[sessionController.generatedCustomWorldProfile],
);
const agentResultPreview = sessionController.agentSession?.resultPreview ?? null;
const agentResultPreviewBlockers = useMemo(
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
[agentResultPreview],
);
const agentResultPreviewQualityFindings = useMemo(
() => agentResultPreview?.qualityFindings ?? [],
[agentResultPreview],
);
const agentResultPreviewSourceLabel = useMemo(() => {
if (!agentResultPreview?.source) {
return null;
}
if (agentResultPreview.source === 'published_profile') {
return '已发布世界';
}
if (agentResultPreview.source === 'session_preview') {
return '会话预览';
}
return '服务端预览';
}, [agentResultPreview]);
const featuredGalleryEntries = useMemo(
() => platformBootstrap.publishedGalleryEntries.slice(0, 6),
[platformBootstrap.publishedGalleryEntries],
);
const creationHubItems =
platformBootstrap.customWorldWorkEntries.length > 0
? platformBootstrap.customWorldWorkEntries
: buildCreationHubFallbackItems(platformBootstrap.savedCustomWorldEntries);
const resultViewError =
autosaveCoordinator.customWorldAutoSaveError ??
sessionController.customWorldError;
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
!sessionController.generatedCustomWorldProfile
) {
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
}
}, [
selectedDetailEntry,
selectionStage,
sessionController.generatedCustomWorldProfile,
setSelectionStage,
]);
const runProtectedAction = useCallback(
(action: () => void) => {
if (!authUi?.requireAuth) {
action();
return;
}
authUi.requireAuth(action);
},
[authUi],
);
const openCreationTypePicker = useCallback(() => {
if (sessionController.isCreatingAgentSession) {
return;
}
if (!hasSavedGame) {
handleStartNewGame();
}
sessionController.setCreationTypeError(null);
setShowCreationTypeModal(true);
}, [
handleStartNewGame,
hasSavedGame,
sessionController,
]);
const leaveAgentWorkspace = useCallback(() => {
enterCreateTab();
sessionController.resetSessionViewState();
sessionController.setGeneratedCustomWorldProfile(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.persistAgentUiState(
sessionController.activeAgentSessionId,
null,
);
setSelectionStage('platform');
}, [
autosaveCoordinator,
enterCreateTab,
sessionController,
setSelectionStage,
]);
const leaveAgentDraftGeneration = useCallback(() => {
if (sessionController.isActiveGenerationRunning) {
return;
}
sessionController.setAgentDraftGenerationStartedAt(null);
sessionController.setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
}, [sessionController, setSelectionStage]);
const leaveAgentDraftResult = useCallback(() => {
sessionController.suppressAgentDraftResultAutoOpen();
sessionController.setGeneratedCustomWorldProfile(null);
sessionController.setCustomWorldError(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
enterCreateTab();
setSelectionStage('platform');
}, [
autosaveCoordinator,
enterCreateTab,
sessionController,
setSelectionStage,
]);
const leaveCustomWorldResult = useCallback(() => {
sessionController.setGeneratedCustomWorldProfile(null);
sessionController.setCustomWorldError(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
}, [
autosaveCoordinator,
selectedDetailEntry,
sessionController,
setSelectionStage,
]);
const handleStartSelectedWorld = useCallback(() => {
if (!selectedDetailEntry) {
return;
}
runProtectedAction(() => {
handleCustomWorldSelect(selectedDetailEntry.profile);
});
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
const creationHubContent = (
<CustomWorldCreationHub
items={creationHubItems}
loading={platformBootstrap.isLoadingPlatform}
error={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ?? sessionController.creationTypeError
}
onBack={() => {
platformBootstrap.setPlatformTab('home');
}}
onRetry={() => {
platformBootstrap.setPlatformError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
);
});
}}
onCreateNew={openCreationTypePicker}
onOpenDraft={(item) => {
runProtectedAction(() => {
void detailNavigation.handleOpenCreationWork(item);
});
}}
onEnterPublished={(profileId) => {
runProtectedAction(() => {
const matchedWork = creationHubItems.find(
(entry) => entry.profileId === profileId,
);
if (!matchedWork) {
return;
}
void detailNavigation.handleOpenCreationWork(matchedWork);
});
}}
/>
);
return (
<>
<AnimatePresence mode="wait">
{selectionStage === 'platform' && (
<motion.div
key="platform-home"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<RpgEntryHomeView
activeTab={platformBootstrap.platformTab}
onTabChange={platformBootstrap.setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={platformBootstrap.saveEntries}
saveError={platformBootstrap.saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={platformBootstrap.publishedGalleryEntries}
myEntries={platformBootstrap.savedCustomWorldEntries}
historyEntries={platformBootstrap.historyEntries}
profileDashboard={platformBootstrap.profileDashboard}
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
platformError={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ??
sessionController.creationTypeError
}
dashboardError={
platformBootstrap.isLoadingDashboard
? null
: platformBootstrap.dashboardError
}
createTabContent={creationHubContent}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void platformBootstrap.handleResumeSaveEntry(entry);
}}
onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
runProtectedAction(() => {
void detailNavigation.openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
detailNavigation.openLibraryDetail(entry);
});
}}
onOpenProfileDashboardCard={() => {
if (platformBootstrap.dashboardError) {
void platformBootstrap.refreshProfileDashboard();
}
}}
/>
</motion.div>
)}
{selectionStage === 'detail' && (
<motion.div
key="platform-detail"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
{detailNavigation.isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{detailNavigation.detailError || '正在读取作品详情...'}
</div>
</div>
) : (
<RpgEntryWorldDetailView
entry={selectedDetailEntry}
isMutating={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
onBack={() => {
detailNavigation.setDetailError(null);
entryNavigation.backToPlatformHome();
}}
onStartGame={handleStartSelectedWorld}
onContinueEdit={
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
detailNavigation.openSavedCustomWorldEditor(
selectedDetailEntry,
);
});
}
: null
}
onPublish={
selectedDetailEntry.visibility === 'draft' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handlePublishSelectedWorld();
});
}
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handleUnpublishSelectedWorld();
});
}
: null
}
onDelete={
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handleDeleteSelectedWorld();
});
}
: null
}
/>
)}
</motion.div>
)}
{selectionStage === 'agent-workspace' && (
<motion.div
key="agent-workspace"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={
<LazyPanelFallback label="正在加载 Agent 共创工作区..." />
}
>
{sessionController.agentSession ? (
<CustomWorldAgentWorkspace
session={sessionController.agentSession}
activeOperation={sessionController.agentOperation}
streamingReplyText={sessionController.streamingAgentReplyText}
isStreamingReply={sessionController.isStreamingAgentReply}
onBack={leaveAgentWorkspace}
onSubmitMessage={(payload) => {
void sessionController.submitAgentMessage(payload);
}}
onExecuteAction={(payload) => {
void sessionController.executeAgentAction(payload);
}}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{sessionController.isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: sessionController.creationTypeError || '正在恢复创作工作区...'}
</div>
</div>
)}
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-generating' && (
<motion.div
key="custom-world-generating"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
>
<CustomWorldGenerationView
settingText={sessionController.agentDraftSettingPreview}
anchorEntries={sessionController.agentDraftAnchorPreviewEntries}
progress={sessionController.agentDraftGenerationProgress}
isGenerating={sessionController.isActiveGenerationRunning}
error={sessionController.activeGenerationError}
onBack={leaveAgentDraftGeneration}
onEditSetting={leaveAgentDraftGeneration}
onRetry={() => {
void sessionController.executeAgentAction({
action: 'draft_foundation',
});
}}
onInterrupt={undefined}
backLabel="返回工作区"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前世界信息"
settingDescription={null}
progressTitle="世界草稿生成进度"
activeBadgeLabel="草稿编译中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-result' &&
sessionController.generatedCustomWorldProfile && (
<motion.div
key="custom-world-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载世界编辑器..." />}
>
<RpgCreationResultView
profile={sessionController.generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={false}
progress={0}
progressLabel=""
error={resultViewError}
onProfileChange={(profile) => {
sessionController.setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(profile),
);
}}
onBack={
sessionController.isAgentDraftResultView
? () => {
void (async () => {
const currentProfile =
sessionController.generatedCustomWorldProfile;
if (!currentProfile) {
leaveAgentDraftResult();
return;
}
await autosaveCoordinator.syncAgentDraftResultProfile(
currentProfile,
);
leaveAgentDraftResult();
})().catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'返回创作前同步草稿失败。',
),
);
});
}
: leaveCustomWorldResult
}
onEditSetting={undefined}
onRegenerate={undefined}
onContinueExpand={undefined}
onEnterWorld={() => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'发布并进入世界失败。',
),
);
});
});
}}
readOnly={false}
compactAgentResultMode={sessionController.isAgentDraftResultView}
backLabel={
sessionController.isAgentDraftResultView
? '返回创作'
: undefined
}
editActionLabel="继续调整设定"
enterWorldActionLabel={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? '发布并进入世界'
: '进入世界'
}
publishReady={
sessionController.isAgentDraftResultView
? Boolean(agentResultPreview?.publishReady)
: true
}
publishBlockers={
sessionController.isAgentDraftResultView
? agentResultPreviewBlockers
: []
}
qualityFindings={
sessionController.isAgentDraftResultView
? agentResultPreviewQualityFindings
: []
}
previewSourceLabel={
sessionController.isAgentDraftResultView
? agentResultPreviewSourceLabel
: null
}
autoSaveState={autosaveCoordinator.customWorldAutoSaveState}
/>
</Suspense>
</motion.div>
)}
</AnimatePresence>
<RpgEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={sessionController.isCreatingAgentSession}
error={sessionController.creationTypeError}
onClose={() => {
if (sessionController.isCreatingAgentSession) {
return;
}
setShowCreationTypeModal(false);
}}
onSelectRpg={() => {
runProtectedAction(() => {
void sessionController.openRpgAgentWorkspace();
});
}}
/>
</>
);
}
export const RpgCreationShellImpl = RpgEntryFlowShellImpl;
export default RpgEntryFlowShellImpl;

View File

@@ -39,7 +39,7 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
import type { AuthUser } from '../../services/authService';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformBrandLogo } from './PlatformBrandLogo';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
@@ -47,9 +47,37 @@ import {
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
resolvePlatformWorldLeadPortrait,
} from './platformWorldPresentation';
} from './rpgEntryWorldPresentation';
export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
featuredEntries: CustomWorldGalleryCard[];
latestEntries: CustomWorldGalleryCard[];
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
historyEntries: PlatformBrowseHistoryEntry[];
profileDashboard: ProfileDashboardSummary | null;
isLoadingPlatform: boolean;
isLoadingDashboard: boolean;
isResumingSaveWorldKey: string | null;
platformError: string | null;
dashboardError: string | null;
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
createTabContent?: ReactNode;
}
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
const HERO_SURFACE_CLASS =
@@ -676,7 +704,7 @@ function ProfileShortcutButton({
);
}
export function PlatformHomeView({
export function RpgEntryHomeView({
activeTab,
onTabChange,
hasSavedGame,
@@ -701,34 +729,7 @@ export function PlatformHomeView({
onOpenLibraryDetail,
onOpenProfileDashboardCard,
createTabContent,
}: {
activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
featuredEntries: CustomWorldGalleryCard[];
latestEntries: CustomWorldGalleryCard[];
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
historyEntries: PlatformBrowseHistoryEntry[];
profileDashboard: ProfileDashboardSummary | null;
isLoadingPlatform: boolean;
isLoadingDashboard: boolean;
isResumingSaveWorldKey: string | null;
platformError: string | null;
dashboardError: string | null;
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
createTabContent?: ReactNode;
}) {
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout();
@@ -1472,7 +1473,7 @@ export function PlatformHomeView({
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4">
<PlatformBrandLogo />
<RpgEntryBrandLogo />
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
@@ -1523,7 +1524,7 @@ export function PlatformHomeView({
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<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 />
<RpgEntryBrandLogo 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-[var(--platform-text-soft)]">
<Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm">
@@ -1604,3 +1605,5 @@ export function PlatformHomeView({
</div>
);
}
export const PlatformHomeView = RpgEntryHomeView;

View File

@@ -9,7 +9,19 @@ import {
formatPlatformWorldTime,
resolvePlatformWorldCoverImage,
resolvePlatformWorldLeadPortrait,
} from './platformWorldPresentation';
} from './rpgEntryWorldPresentation';
export interface RpgEntryWorldDetailViewProps {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
isMutating: boolean;
error: string | null;
onBack: () => void;
onStartGame: () => void;
onContinueEdit?: (() => void) | null;
onPublish?: (() => void) | null;
onDelete?: (() => void) | null;
onUnpublish?: (() => void) | null;
}
function ActionButton({
label,
@@ -41,7 +53,7 @@ function ActionButton({
);
}
export function PlatformWorldDetailView({
export function RpgEntryWorldDetailView({
entry,
isMutating,
error,
@@ -51,19 +63,10 @@ export function PlatformWorldDetailView({
onPublish,
onDelete,
onUnpublish,
}: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
isMutating: boolean;
error: string | null;
onBack: () => void;
onStartGame: () => void;
onContinueEdit?: (() => void) | null;
onPublish?: (() => void) | null;
onDelete?: (() => void) | null;
onUnpublish?: (() => void) | null;
}) {
}: RpgEntryWorldDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const canStartGame = entry.visibility === 'published';
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
).slice(0, 3);
@@ -238,9 +241,10 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-4 flex flex-col gap-3">
<ActionButton
label="开始游戏"
label={canStartGame ? '开始游戏' : '请先发布作品'}
onClick={onStartGame}
tone="primary"
disabled={!canStartGame || isMutating}
/>
{onContinueEdit ? (
<ActionButton
@@ -286,3 +290,5 @@ export function PlatformWorldDetailView({
</div>
);
}
export const PlatformWorldDetailView = RpgEntryWorldDetailView;

View File

@@ -0,0 +1,27 @@
export {
RpgEntryCharacterSelectView,
type RpgEntryCharacterSelectViewProps,
} from './RpgEntryCharacterSelectView';
export {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
type SelectionStage,
} from './RpgEntryFlowShell';
export {
type PlatformHomeTab,
RpgEntryHomeView,
type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
export {
RpgEntryWorldDetailView,
type RpgEntryWorldDetailViewProps,
} from './RpgEntryWorldDetailView';
export { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
export { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
export { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
export { useRpgCreationSessionController } from './useRpgCreationSessionController';
export { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
export { useRpgEntryCharacterSelect } from './useRpgEntryCharacterSelect';
export { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
export { useRpgEntryNavigation } from './useRpgEntryNavigation';
export { useRpgEntrySaveResume } from './useRpgEntrySaveResume';

View File

@@ -0,0 +1,110 @@
import type {
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
return error instanceof Error ? error.message : fallback;
}
export function createFailedRpgEntryAgentOperation(params: {
type: CustomWorldAgentOperationRecord['type'];
phaseLabel: string;
error: string;
}): CustomWorldAgentOperationRecord {
return {
operationId: `local-failed-${Date.now()}`,
type: params.type,
status: 'failed',
phaseLabel: params.phaseLabel,
phaseDetail: params.error,
progress: 100,
error: params.error,
};
}
export function buildOptimisticRpgEntryAgentMessage(
payload: Pick<CustomWorldAgentMessage, 'id' | 'role' | 'kind' | 'text'>,
): CustomWorldAgentMessage {
return {
...payload,
createdAt: new Date().toISOString(),
relatedOperationId: null,
};
}
export function normalizeRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
const foundationText = buildCustomWorldCreatorIntentFoundationText(
profile.creatorIntent,
).trim();
if (!foundationText || foundationText === profile.settingText.trim()) {
return profile;
}
return {
...profile,
settingText: foundationText,
} satisfies CustomWorldProfile;
}
export function stringifyRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
return JSON.stringify(normalizeRpgEntryAgentBackedProfile(profile));
}
export function buildRpgEntryCreationHubFallbackItems(
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
): CustomWorldWorkSummary[] {
return entries
.filter((entry) => entry.visibility === 'published')
.map((entry) => ({
workId: `fallback:${entry.profileId}`,
sourceType: 'published_profile',
status: 'published',
title: entry.worldName,
subtitle: entry.subtitle || '已发布作品',
summary: entry.summaryText || '继续补完这个世界的设定与游玩入口。',
coverImageSrc: entry.coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
stage: null,
stageLabel: '已发布',
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: entry.profileId,
canResume: false,
canEnterWorld: true,
}));
}
/**
* 兼容创作链工作包已经接入的旧 helper 命名,避免本轮迁移波及其他并行改动。
*/
export const resolveRpgCreationErrorMessage = resolveRpgEntryErrorMessage;
export const createFailedAgentOperation =
createFailedRpgEntryAgentOperation;
export const buildOptimisticAgentMessage =
buildOptimisticRpgEntryAgentMessage;
export const normalizeAgentBackedProfile =
normalizeRpgEntryAgentBackedProfile;
export const stringifyAgentBackedProfile =
stringifyRpgEntryAgentBackedProfile;
export const buildCreationHubFallbackItems =
buildRpgEntryCreationHubFallbackItems;

View File

@@ -0,0 +1,39 @@
import type {
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile, GameState } from '../../types';
export type SelectionStage =
| 'platform'
| 'detail'
| 'agent-workspace'
| 'custom-world-generating'
| 'custom-world-result';
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
export type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
};
export type RpgEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
/**
* 兼容旧创作链入口的 props 命名,避免并行工作包在迁移期间断开引用。
*/
export type RpgCreationShellProps = RpgEntryFlowShellProps;

View File

@@ -28,9 +28,7 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldLeadPortrait(
entry: PlatformWorldCardLike,
) {
export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
if (!isLibraryWorldEntry(entry)) {
return '';
}
@@ -72,7 +70,9 @@ export function formatPlatformWorldTime(value: string | null) {
});
}
export function describePlatformThemeLabel(themeMode: PlatformWorldCardLike['themeMode']) {
export function describePlatformThemeLabel(
themeMode: PlatformWorldCardLike['themeMode'],
) {
switch (themeMode) {
case 'martial':
return '江湖';

View File

@@ -0,0 +1,109 @@
import { useEffect } from 'react';
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import {
getRpgCreationOperation,
} from '../../services/rpg-creation';
import {
createFailedAgentOperation,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
type UseRpgCreationAgentOperationPollingParams = {
activeAgentSessionId: string | null;
activeAgentOperationId: string | null;
userId: string | null | undefined;
setAgentOperation: (
operation: CustomWorldAgentOperationRecord | null,
) => void;
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
};
/**
* 只负责当前 Agent operation 的轮询与完成态刷新。
* 壳层不再直接维护轮询 interval 与失败兜底细节。
*/
export function useRpgCreationAgentOperationPolling(
params: UseRpgCreationAgentOperationPollingParams,
) {
const {
activeAgentSessionId,
activeAgentOperationId,
userId,
setAgentOperation,
persistAgentUiState,
syncAgentSessionSnapshot,
} = params;
useEffect(() => {
if (!activeAgentSessionId || !activeAgentOperationId || !userId) {
return;
}
let cancelled = false;
const pollOperation = async () => {
try {
const nextOperation = await getRpgCreationOperation(
activeAgentSessionId,
activeAgentOperationId,
);
if (cancelled) {
return;
}
setAgentOperation(nextOperation);
if (
nextOperation.status === 'completed' ||
nextOperation.status === 'failed'
) {
persistAgentUiState(activeAgentSessionId, null);
await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
);
}
} catch (error) {
if (cancelled) {
return;
}
setAgentOperation(
createFailedAgentOperation({
type: 'process_message',
phaseLabel: '读取操作状态失败',
error: resolveRpgCreationErrorMessage(error, '读取共创操作状态失败。'),
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
};
void pollOperation();
const intervalId = window.setInterval(() => {
void pollOperation();
}, 1200);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [
activeAgentOperationId,
activeAgentSessionId,
persistAgentUiState,
setAgentOperation,
syncAgentSessionSnapshot,
userId,
]);
}

View File

@@ -0,0 +1,92 @@
import { useCallback } from 'react';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
type UseRpgCreationEnterWorldParams = {
isAgentDraftResultView: boolean;
activeAgentSessionId: string | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
agentSessionProfile: CustomWorldProfile | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
syncAgentDraftResultProfile: (
profile: CustomWorldProfile,
) => Promise<{
profile: CustomWorldProfile | null;
session: CustomWorldAgentSessionSnapshot | null;
}>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
/**
* 统一“进入世界”前的最终同步策略。
* 非 Agent 草稿结果直接进入Agent 草稿结果必须先把当前结果页并回 session。
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
) {
const {
isAgentDraftResultView,
activeAgentSessionId,
generatedCustomWorldProfile,
agentSessionProfile,
agentSession,
handleCustomWorldSelect,
executePublishWorld,
syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile,
} = params;
const enterWorldFromCurrentResult = useCallback(async () => {
if (!generatedCustomWorldProfile) {
return;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile);
return;
}
const latestResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
const latestSession = latestResult.session ?? agentSession;
const canEnterPublishedWorld =
latestSession?.stage === 'published' &&
latestSession.resultPreview?.canEnterWorld;
if (canEnterPublishedWorld) {
handleCustomWorldSelect(latestProfile);
return;
}
const publishedSession = await executePublishWorld();
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
latestProfile;
setGeneratedCustomWorldProfile(publishedProfile);
handleCustomWorldSelect(publishedProfile);
}, [
activeAgentSessionId,
agentSession,
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
]);
return {
enterWorldFromCurrentResult,
};
}

View File

@@ -0,0 +1,392 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import type { CustomWorldProfile } from '../../types';
import {
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
stringifyAgentBackedProfile,
} from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
SelectionStage,
SyncedAgentDraftResult,
} from './rpgEntryTypes';
type UseRpgCreationResultAutosaveParams = {
selectionStage: SelectionStage;
activeAgentSessionId: string | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
isAgentDraftResultView: boolean;
userId: string | null | undefined;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
setAgentOperation: (
operation: CustomWorldAgentOperationRecord | null,
) => void;
setSavedCustomWorldEntries: (
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
) => void;
setSelectedDetailEntry: (
updater:
| CustomWorldLibraryEntry<CustomWorldProfile>
| null
| ((
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
) => void;
refreshCustomWorldWorks: () => Promise<unknown>;
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
) => CustomWorldProfile | null;
};
/**
* 协调结果页自动保存与 Agent 草稿同步。
* 这里统一维护去重签名、延时保存和“先同步 session 再落作品库”的顺序。
*/
export function useRpgCreationResultAutosave(
params: UseRpgCreationResultAutosaveParams,
) {
const {
selectionStage,
activeAgentSessionId,
agentSession,
generatedCustomWorldProfile,
isAgentDraftResultView,
userId,
setGeneratedCustomWorldProfile,
setAgentOperation,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
refreshCustomWorldWorks,
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile,
} = params;
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0);
const latestAgentResultSyncSignatureRef = useRef<string | null>(null);
const isCustomWorldAutoSaveBusyRef = useRef(false);
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
useState<CustomWorldAutoSaveState>('idle');
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
string | null
>(null);
const resetAutoSaveTrackingToIdle = useCallback(() => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
}
lastAutoSavedProfileSignatureRef.current = null;
latestAgentResultSyncSignatureRef.current = null;
setCustomWorldAutoSaveState('idle');
setCustomWorldAutoSaveError(null);
}, []);
const markAutoSavedProfile = useCallback((profile: CustomWorldProfile) => {
lastAutoSavedProfileSignatureRef.current =
stringifyAgentBackedProfile(profile);
}, []);
const saveGeneratedCustomWorld = useCallback(
async (profile = generatedCustomWorldProfile) => {
if (!profile) {
return null;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const requestId = latestAutoSaveRequestIdRef.current + 1;
latestAutoSaveRequestIdRef.current = requestId;
setCustomWorldAutoSaveState('saving');
setCustomWorldAutoSaveError(null);
try {
const mutation =
await upsertRpgWorldProfile(normalizedProfile);
if (latestAutoSaveRequestIdRef.current !== requestId) {
return mutation;
}
lastAutoSavedProfileSignatureRef.current = profileSignature;
setSavedCustomWorldEntries(mutation.entries);
if (userId) {
void refreshCustomWorldWorks().catch(() => {});
}
setSelectedDetailEntry((current) => {
if (!current || current.profileId === mutation.entry.profileId) {
return mutation.entry;
}
return current;
});
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
return mutation;
} catch (error) {
if (latestAutoSaveRequestIdRef.current !== requestId) {
return null;
}
setCustomWorldAutoSaveState('error');
setCustomWorldAutoSaveError(
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
);
return null;
}
},
[
generatedCustomWorldProfile,
refreshCustomWorldWorks,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
userId,
],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const latestSessionProfile = buildDraftResultProfile(agentSession);
const latestSessionProfileSignature = latestSessionProfile
? stringifyAgentBackedProfile(latestSessionProfile)
: '';
if (latestSessionProfileSignature === profileSignature) {
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
if (latestAgentResultSyncSignatureRef.current === profileSignature) {
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
const { operation } = await executeRpgCreationAction(
activeAgentSessionId,
{
action: 'sync_result_profile',
profile: normalizedProfile as unknown as Record<string, unknown>,
},
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
for (let attempt = 0; attempt < 60; attempt += 1) {
const latestOperation = await getRpgCreationOperation(
activeAgentSessionId,
operation.operationId,
);
setAgentOperation(latestOperation);
if (latestOperation.status === 'failed') {
throw new Error(
latestOperation.error ||
latestOperation.phaseDetail ||
'同步结果页世界快照失败。',
);
}
if (latestOperation.status === 'completed') {
persistAgentUiState(activeAgentSessionId, null);
const latestSession = await syncAgentSessionSnapshot(
activeAgentSessionId,
);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,
);
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: latestSession,
profile: latestProfile,
} satisfies SyncedAgentDraftResult;
}
await new Promise((resolve) => window.setTimeout(resolve, 200));
}
throw new Error('同步结果页世界快照超时。');
},
[
activeAgentSessionId,
agentSession,
buildDraftResultProfile,
persistAgentUiState,
setAgentOperation,
setGeneratedCustomWorldProfile,
syncAgentSessionSnapshot,
],
);
const executeAgentActionAndWait = useCallback(
async (action: Parameters<typeof executeRpgCreationAction>[1]) => {
if (!activeAgentSessionId) {
return null;
}
const { operation } = await executeRpgCreationAction(
activeAgentSessionId,
action,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
for (let attempt = 0; attempt < 60; attempt += 1) {
const latestOperation = await getRpgCreationOperation(
activeAgentSessionId,
operation.operationId,
);
setAgentOperation(latestOperation);
if (latestOperation.status === 'failed') {
throw new Error(
latestOperation.error ||
latestOperation.phaseDetail ||
'执行共创操作失败。',
);
}
if (latestOperation.status === 'completed') {
persistAgentUiState(activeAgentSessionId, null);
return syncAgentSessionSnapshot(activeAgentSessionId);
}
await new Promise((resolve) => window.setTimeout(resolve, 200));
}
throw new Error('执行共创操作超时。');
},
[
activeAgentSessionId,
persistAgentUiState,
setAgentOperation,
syncAgentSessionSnapshot,
],
);
useEffect(
() => () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!generatedCustomWorldProfile) {
resetAutoSaveTrackingToIdle();
return;
}
if (selectionStage !== 'custom-world-result') {
return;
}
if (isCustomWorldAutoSaveBusyRef.current) {
return;
}
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
return;
}
setCustomWorldAutoSaveState('saving');
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
}
const profileToSave = generatedCustomWorldProfile;
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
void (async () => {
isCustomWorldAutoSaveBusyRef.current = true;
try {
let latestProfileToSave = normalizeAgentBackedProfile(profileToSave);
if (isAgentDraftResultView) {
const syncedResult =
await syncAgentDraftResultProfile(profileToSave);
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
latestProfileToSave = normalizeAgentBackedProfile(
syncedResult.profile ?? profileToSave,
);
}
await saveGeneratedCustomWorld(latestProfileToSave);
} catch (error) {
setCustomWorldAutoSaveState('error');
setCustomWorldAutoSaveError(
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
);
} finally {
isCustomWorldAutoSaveBusyRef.current = false;
}
})();
customWorldAutoSaveTimeoutRef.current = null;
}, 600);
return () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
}
};
}, [
generatedCustomWorldProfile,
isAgentDraftResultView,
resetAutoSaveTrackingToIdle,
saveGeneratedCustomWorld,
selectionStage,
syncAgentDraftResultProfile,
]);
return {
customWorldAutoSaveState,
setCustomWorldAutoSaveState,
customWorldAutoSaveError,
setCustomWorldAutoSaveError,
resetAutoSaveTrackingToIdle,
markAutoSavedProfile,
saveGeneratedCustomWorld,
syncAgentDraftResultProfile,
executeAgentActionAndWait,
};
}

View File

@@ -0,0 +1,570 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import {
buildAgentDraftFoundationAnchorEntries,
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
isDraftFoundationOperationRunning,
} from '../../services/customWorldAgentGenerationProgress';
import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationSession,
streamRpgCreationMessage,
} from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import {
buildOptimisticAgentMessage,
createFailedAgentOperation,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type {
CustomWorldGenerationViewSource,
CustomWorldResultViewSource,
SelectionStage,
} from './rpgEntryTypes';
type UseRpgCreationSessionControllerParams = {
userId: string | null | undefined;
openLoginModal?: ((postLoginAction?: (() => void) | null) => void) | undefined;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
enterCreateTab?: (() => void) | undefined;
onSessionOpened?: (() => void) | undefined;
};
export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams,
) {
const {
userId,
openLoginModal,
selectionStage,
setSelectionStage,
enterCreateTab,
onSessionOpened,
} = params;
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
string | null
>(() => initialAgentUiStateRef.current.activeSessionId ?? null);
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
string | null
>(() => initialAgentUiStateRef.current.activeOperationId ?? null);
const [agentSession, setAgentSession] =
useState<CustomWorldAgentSessionSnapshot | null>(null);
const [agentOperation, setAgentOperation] =
useState<CustomWorldAgentOperationRecord | null>(null);
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
useState<CustomWorldGenerationViewSource>(null);
const [customWorldResultViewSource, setCustomWorldResultViewSource] =
useState<CustomWorldResultViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
setActiveAgentSessionId(nextSessionId);
setActiveAgentOperationId(nextOperationId);
writeCustomWorldAgentUiState({
activeSessionId: nextSessionId,
activeOperationId: nextOperationId,
});
},
[],
);
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
const nextSession = await getRpgCreationSession(sessionId);
setAgentSession(nextSession);
return nextSession;
}, []);
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
return;
}
enterCreateTab?.();
if (!userId) {
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
openLoginModal?.(() => {
setSelectionStage('agent-workspace');
});
}
return;
}
hasAppliedInitialAgentWorkspaceRef.current = true;
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, setSelectionStage, userId]);
useEffect(() => {
if (!activeAgentSessionId) {
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
if (!userId) {
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
let cancelled = false;
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
.then(() => {
if (!cancelled) {
setCreationTypeError(null);
}
})
.catch((error) => {
if (cancelled) {
return;
}
setCreationTypeError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
persistAgentUiState(null, null);
enterCreateTab?.();
setSelectionStage('platform');
})
.finally(() => {
if (!cancelled) {
setIsLoadingAgentSession(false);
}
});
return () => {
cancelled = true;
};
}, [
activeAgentSessionId,
enterCreateTab,
persistAgentUiState,
setSelectionStage,
syncAgentSessionSnapshot,
userId,
]);
useEffect(() => {
if (
!isDraftFoundationOperationRunning(agentOperation) ||
agentDraftGenerationStartedAt
) {
return;
}
setAgentDraftGenerationStartedAt(Date.now());
}, [agentDraftGenerationStartedAt, agentOperation]);
useEffect(() => {
if (
selectionStage !== 'custom-world-generating' ||
customWorldGenerationViewSource !== 'agent-draft-foundation' ||
!isDraftFoundationOperation(agentOperation) ||
agentOperation.status !== 'completed'
) {
return;
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void (async () => {
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
)
: agentSession;
if (cancelled) {
return;
}
const draftResultProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession ?? agentSession,
);
if (!draftResultProfile) {
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(draftResultProfile),
);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
})();
}, 900);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
activeAgentSessionId,
agentOperation,
agentSession,
customWorldGenerationViewSource,
selectionStage,
setSelectionStage,
syncAgentSessionSnapshot,
]);
const agentDraftSettingPreview = useMemo(
() => buildAgentDraftFoundationSettingText(agentSession),
[agentSession],
);
const agentDraftAnchorPreviewEntries = useMemo(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => rpgCreationPreviewAdapter.buildPreviewFromSession(agentSession),
[agentSession],
);
const shouldAutoOpenAgentDraftResult = useMemo(
() =>
Boolean(
agentDraftResultProfile &&
agentSession &&
(agentSession.stage === 'object_refining' ||
agentSession.stage === 'visual_refining' ||
agentSession.stage === 'long_tail_review' ||
agentSession.stage === 'ready_to_publish' ||
agentSession.stage === 'published') &&
agentSession.draftCards.length > 0,
),
[agentDraftResultProfile, agentSession],
);
const agentDraftGenerationProgress = useMemo(
() =>
buildAgentDraftFoundationGenerationProgress(
agentOperation,
agentDraftGenerationStartedAt,
),
[agentDraftGenerationStartedAt, agentOperation],
);
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const isActiveGenerationRunning =
isDraftFoundationOperationRunning(agentOperation);
const activeGenerationError =
isDraftFoundationOperation(agentOperation) &&
agentOperation.status === 'failed'
? agentOperation.error || agentOperation.phaseDetail
: null;
useEffect(() => {
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
return;
}
if (isAgentDraftResultAutoOpenSuppressedRef.current) {
return;
}
if (selectionStage === 'agent-workspace') {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setSelectionStage('custom-world-result');
return;
}
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
) {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
}
}, [
agentDraftResultProfile,
generatedCustomWorldProfile,
selectionStage,
setSelectionStage,
shouldAutoOpenAgentDraftResult,
]);
const openRpgAgentWorkspace = useCallback(
async (seedText = '') => {
if (isCreatingAgentSession) {
return;
}
setIsCreatingAgentSession(true);
setCreationTypeError(null);
isAgentDraftResultAutoOpenSuppressedRef.current = false;
try {
const { session } = await createRpgCreationSession(
seedText ? { seedText } : {},
);
setAgentSession(session);
setAgentOperation(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
enterCreateTab?.();
onSessionOpened?.();
persistAgentUiState(session.sessionId, null);
setSelectionStage('agent-workspace');
} catch (error) {
setCreationTypeError(
resolveRpgCreationErrorMessage(error, '开启共创工作台失败。'),
);
} finally {
setIsCreatingAgentSession(false);
}
},
[
enterCreateTab,
isCreatingAgentSession,
onSessionOpened,
persistAgentUiState,
setSelectionStage,
],
);
const submitAgentMessage = useCallback(
async (payload: SendCustomWorldAgentMessageRequest) => {
if (!activeAgentSessionId) {
return;
}
const optimisticUserMessage = buildOptimisticAgentMessage({
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
});
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setAgentSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticUserMessage],
updatedAt: optimisticUserMessage.createdAt,
}
: current,
);
try {
const nextSession = await streamRpgCreationMessage(
activeAgentSessionId,
payload,
{
onUpdate: (text) => {
setStreamingAgentReplyText(text);
},
},
);
setAgentSession(nextSession);
setAgentOperation(null);
setStreamingAgentReplyText('');
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
'发送共创消息失败。',
);
setAgentSession((current) =>
current
? {
...current,
messages: [
...current.messages,
buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
}),
],
updatedAt: new Date().toISOString(),
}
: current,
);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
}
},
[activeAgentSessionId, persistAgentUiState],
);
const executeAgentAction = useCallback(
async (payload: CustomWorldAgentActionRequest) => {
if (!activeAgentSessionId) {
return;
}
const isDraftFoundationAction = payload.action === 'draft_foundation';
if (isDraftFoundationAction) {
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setAgentDraftGenerationStartedAt(Date.now());
setSelectionStage('custom-world-generating');
}
try {
const { operation } = await executeRpgCreationAction(
activeAgentSessionId,
payload,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
'执行共创操作失败。',
);
setAgentOperation(
createFailedAgentOperation({
type:
payload.action === 'draft_foundation'
? 'draft_foundation'
: payload.action,
phaseLabel: '执行操作失败',
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
},
[activeAgentSessionId, persistAgentUiState, setSelectionStage],
);
const setNormalizedGeneratedCustomWorldProfile = useCallback(
(profile: CustomWorldProfile | null) => {
setGeneratedCustomWorldProfile(
profile ? normalizeAgentBackedProfile(profile) : null,
);
},
[],
);
const resetSessionViewState = useCallback(() => {
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
}, []);
const suppressAgentDraftResultAutoOpen = useCallback(() => {
isAgentDraftResultAutoOpenSuppressedRef.current = true;
}, []);
const releaseAgentDraftResultAutoOpenSuppression = useCallback(() => {
isAgentDraftResultAutoOpenSuppressedRef.current = false;
}, []);
return {
initialAgentSessionId: initialAgentUiStateRef.current.activeSessionId ?? null,
isCreatingAgentSession,
activeAgentSessionId,
activeAgentOperationId,
agentSession,
setAgentSession,
agentOperation,
setAgentOperation,
resetSessionViewState,
streamingAgentReplyText,
isStreamingAgentReply,
isLoadingAgentSession,
creationTypeError,
setCreationTypeError,
customWorldError,
setCustomWorldError,
generatedCustomWorldProfile,
setGeneratedCustomWorldProfile: setNormalizedGeneratedCustomWorldProfile,
rawSetGeneratedCustomWorldProfile: setGeneratedCustomWorldProfile,
customWorldGenerationViewSource,
setCustomWorldGenerationViewSource,
customWorldResultViewSource,
setCustomWorldResultViewSource,
agentDraftGenerationStartedAt,
setAgentDraftGenerationStartedAt,
agentDraftSettingPreview,
agentDraftAnchorPreviewEntries,
agentDraftResultProfile,
agentDraftGenerationProgress,
isAgentDraftGenerationView,
isAgentDraftResultView,
isActiveGenerationRunning,
activeGenerationError,
isAgentDraftResultAutoOpenSuppressedRef,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression,
persistAgentUiState,
syncAgentSessionSnapshot,
openRpgAgentWorkspace,
submitAgentMessage,
executeAgentAction,
};
}

View File

@@ -0,0 +1,346 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import { listRpgCreationWorks } from '../../services/rpg-creation/index';
import {
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory,
listRpgProfileSaveArchives,
resumeRpgProfileSaveArchive,
upsertRpgProfileBrowseHistory,
} from '../../services/rpg-entry';
import type { CustomWorldProfile } from '../../types';
import type { PlatformHomeTab } from './RpgEntryHomeView';
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
type UseRpgEntryBootstrapParams = {
user: AuthUser | null | undefined;
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
handleContinueGame: (
snapshot?: HydratedSavedGameSnapshot | null,
) => void;
hasInitialAgentSession: boolean;
};
export function useRpgEntryBootstrap(
params: UseRpgEntryBootstrapParams,
) {
const { user, getProfileDashboard, handleContinueGame, hasInitialAgentSession } =
params;
const isAuthenticated = Boolean(user);
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
CustomWorldLibraryEntry<CustomWorldProfile>[]
>([]);
const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState<
CustomWorldWorkSummary[]
>([]);
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
CustomWorldGalleryCard[]
>([]);
const [historyEntries, setHistoryEntries] = useState<
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
string | null
>(null);
const [profileDashboard, setProfileDashboard] =
useState<ProfileDashboardSummary | null>(null);
const refreshProfileDashboard = useCallback(async () => {
if (!user) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
return;
}
setIsLoadingDashboard(true);
setDashboardError(null);
try {
setProfileDashboard(await getProfileDashboard());
} catch (error) {
setDashboardError(
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
);
} finally {
setIsLoadingDashboard(false);
}
}, [getProfileDashboard, user]);
const refreshCustomWorldWorks = useCallback(async () => {
if (!user) {
setCustomWorldWorkEntries([]);
return [];
}
const nextItems = await listRpgCreationWorks();
setCustomWorldWorkEntries(nextItems);
return nextItems;
}, [user]);
const refreshPublishedGallery = useCallback(async () => {
const nextEntries = await listRpgEntryWorldGallery();
setPublishedGalleryEntries(nextEntries);
return nextEntries;
}, []);
const refreshSavedCustomWorldLibrary = useCallback(async () => {
if (!user) {
setSavedCustomWorldEntries([]);
return [];
}
const nextEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(nextEntries);
return nextEntries;
}, [user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
setHistoryError(null);
try {
const syncedEntries = await upsertRpgProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
} catch (error) {
setHistoryError(
resolveRpgEntryErrorMessage(error, '写入浏览历史失败。'),
);
}
},
[],
);
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!user || isResumingSaveWorldKey) {
return;
}
setIsResumingSaveWorldKey(entry.worldKey);
setSaveError(null);
try {
const resumedArchive = await resumeRpgProfileSaveArchive(entry.worldKey);
setSaveEntries((currentEntries) =>
currentEntries.map((currentEntry) =>
currentEntry.worldKey === resumedArchive.entry.worldKey
? resumedArchive.entry
: currentEntry,
),
);
handleContinueGame(resumedArchive.snapshot);
} catch (error) {
setSaveError(resolveRpgEntryErrorMessage(error, '恢复存档失败。'));
} finally {
setIsResumingSaveWorldKey(null);
}
},
[handleContinueGame, isResumingSaveWorldKey, user],
);
useEffect(() => {
let isActive = true;
void (async () => {
setHistoryEntries([]);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(isAuthenticated);
setDashboardError(null);
if (!isAuthenticated) {
setSavedCustomWorldEntries([]);
setCustomWorldWorkEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
try {
const [
libraryEntriesResult,
workEntriesResult,
galleryEntriesResult,
dashboardResult,
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
isAuthenticated
? listRpgEntryWorldLibrary()
: Promise.resolve([]),
isAuthenticated
? listRpgCreationWorks()
: Promise.resolve([]),
listRpgEntryWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated ? listRpgProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? listRpgProfileSaveArchives() : Promise.resolve([]),
]);
if (!isActive) {
return;
}
if (libraryEntriesResult.status === 'fulfilled') {
setSavedCustomWorldEntries(libraryEntriesResult.value);
} else {
setSavedCustomWorldEntries([]);
}
if (workEntriesResult.status === 'fulfilled') {
setCustomWorldWorkEntries(workEntriesResult.value);
} else {
setCustomWorldWorkEntries([]);
}
if (galleryEntriesResult.status === 'fulfilled') {
setPublishedGalleryEntries(galleryEntriesResult.value);
} else {
setPublishedGalleryEntries([]);
}
if (
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
(isAuthenticated && workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
libraryEntriesResult.status === 'rejected'
? libraryEntriesResult.reason
: workEntriesResult.status === 'rejected'
? workEntriesResult.reason
: galleryEntriesResult.status === 'rejected'
? galleryEntriesResult.reason
: null;
setPlatformError(
resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'),
);
}
if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value);
} else if (isAuthenticated) {
setProfileDashboard(null);
setDashboardError(
resolveRpgEntryErrorMessage(
dashboardResult.reason,
'读取个人数据看板失败。',
),
);
}
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else if (isAuthenticated) {
setHistoryError(
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
}
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveRpgEntryErrorMessage(
saveArchivesResult.reason,
'读取存档列表失败。',
),
);
}
const nextPlatformBootstrapUserId = user?.id ?? null;
if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!hasInitialAgentSession) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
}
} finally {
if (isActive) {
setIsLoadingPlatform(false);
setIsLoadingDashboard(false);
}
}
})();
return () => {
isActive = false;
};
}, [getProfileDashboard, hasInitialAgentSession, isAuthenticated, user]);
return {
isAuthenticated,
platformTab,
setPlatformTab,
savedCustomWorldEntries,
setSavedCustomWorldEntries,
customWorldWorkEntries,
setCustomWorldWorkEntries,
publishedGalleryEntries,
setPublishedGalleryEntries,
historyEntries,
setHistoryEntries,
saveEntries,
setSaveEntries,
platformError,
setPlatformError,
dashboardError,
setDashboardError,
historyError,
setHistoryError,
saveError,
setSaveError,
isLoadingPlatform,
isLoadingDashboard,
isResumingSaveWorldKey,
profileDashboard,
setProfileDashboard,
refreshProfileDashboard,
refreshCustomWorldWorks,
refreshPublishedGallery,
refreshSavedCustomWorldLibrary,
appendBrowseHistoryEntry,
handleResumeSaveEntry,
};
}
/**
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
*/
export const useRpgCreationPlatformBootstrap = useRpgEntryBootstrap;

View File

@@ -0,0 +1,46 @@
import { useCallback } from 'react';
import type { Character, CustomWorldProfile, GameState } from '../../types';
type UseRpgEntryCharacterSelectParams = {
gameState: GameState;
handleBackToWorldSelect: () => void;
setSelectionStage: (stage: 'platform') => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
};
/**
* 统一角色选择页的返回与确认动作,保持主阶段路由器里只做装配。
*/
export function useRpgEntryCharacterSelect(
params: UseRpgEntryCharacterSelectParams,
) {
const {
gameState,
handleBackToWorldSelect,
setSelectionStage,
handleCharacterSelect,
} = params;
const customWorldProfile: CustomWorldProfile | null =
gameState.customWorldProfile;
const handleBack = useCallback(() => {
handleBackToWorldSelect();
setSelectionStage('platform');
}, [handleBackToWorldSelect, setSelectionStage]);
const handleConfirm = useCallback(
(character: Character) => {
handleCharacterSelect(character);
},
[handleCharacterSelect],
);
return {
worldType: gameState.worldType,
customWorldProfile,
handleBack,
handleConfirm,
};
}

View File

@@ -0,0 +1,444 @@
import { useCallback, useEffect, useState } from 'react';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
resolveRpgEntryErrorMessage,
} from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
CustomWorldGenerationViewSource,
CustomWorldResultViewSource,
SelectionStage,
} from './rpgEntryTypes';
type UseRpgEntryLibraryDetailParams = {
userId: string | null | undefined;
selectedDetailEntry: CustomWorldLibraryEntry<CustomWorldProfile> | null;
setSelectedDetailEntry: (
entry:
| CustomWorldLibraryEntry<CustomWorldProfile>
| null
| ((
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
) => void;
savedCustomWorldEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
setSavedCustomWorldEntries: (
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
) => void;
setGeneratedCustomWorldProfile: (
profile: CustomWorldProfile | null,
) => void;
setCustomWorldError: (error: string | null) => void;
setCustomWorldAutoSaveError: (error: string | null) => void;
setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void;
setCustomWorldGenerationViewSource: (
source: CustomWorldGenerationViewSource,
) => void;
setCustomWorldResultViewSource: (
source: CustomWorldResultViewSource,
) => void;
setSelectionStage: (stage: SelectionStage) => void;
setPlatformTabToCreate: () => void;
setPlatformError: (error: string | null) => void;
appendBrowseHistoryEntry: (
entry: PlatformBrowseHistoryWriteEntry,
) => Promise<void>;
refreshCustomWorldWorks: () => Promise<unknown>;
refreshPublishedGallery: () => Promise<unknown>;
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
) => CustomWorldProfile | null;
suppressAgentDraftResultAutoOpen: () => void;
releaseAgentDraftResultAutoOpenSuppression: () => void;
resetAutoSaveTrackingToIdle: () => void;
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
};
/**
* 负责平台详情、创作作品入口和结果页打开路径。
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
*/
export function useRpgEntryLibraryDetail(
params: UseRpgEntryLibraryDetailParams,
) {
const {
userId,
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries,
setSavedCustomWorldEntries,
setGeneratedCustomWorldProfile,
setCustomWorldError,
setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState,
setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate,
setPlatformError,
appendBrowseHistoryEntry,
refreshCustomWorldWorks,
refreshPublishedGallery,
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle,
markAutoSavedProfile,
} = params;
const [detailError, setDetailError] = useState<string | null>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
useEffect(() => {
if (!selectedDetailEntry) {
return;
}
const nextOwnedEntry = savedCustomWorldEntries.find(
(entry) =>
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
entry.profileId === selectedDetailEntry.profileId,
);
if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) {
setSelectedDetailEntry(nextOwnedEntry);
}
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
const openLibraryDetail = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
if (entry.visibility === 'published') {
void appendBrowseHistoryEntry({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
});
}
setSelectedDetailEntry(entry);
setDetailError(null);
setSelectionStage('detail');
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const openGalleryDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setSelectionStage('detail');
setIsDetailLoading(true);
setDetailError(null);
try {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
setSelectedDetailEntry(detailEntry);
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
} catch (error) {
setSelectedDetailEntry(null);
setDetailError(
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
);
} finally {
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const openSavedCustomWorldEditor = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
setSelectedDetailEntry(entry);
const normalizedProfile = normalizeRpgEntryAgentBackedProfile(
entry.profile,
);
setGeneratedCustomWorldProfile(normalizedProfile);
markAutoSavedProfile(normalizedProfile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('saved-profile');
setSelectionStage('custom-world-result');
},
[
markAutoSavedProfile,
setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState,
setCustomWorldError,
setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource,
setGeneratedCustomWorldProfile,
setSelectedDetailEntry,
setSelectionStage,
],
);
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
persistAgentUiState(work.sessionId, null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
resetAutoSaveTrackingToIdle();
const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen();
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
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) {
return;
}
try {
let matchedEntry = savedCustomWorldEntries.find(
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry && userId) {
const latestLibraryEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(latestLibraryEntries);
matchedEntry = latestLibraryEntries.find(
(entry) => entry.profileId === work.profileId,
);
}
if (matchedEntry) {
openLibraryDetail(matchedEntry);
return;
}
setPlatformError('未找到对应作品,请刷新后重试。');
} catch (error) {
setPlatformError(
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
);
}
},
[
buildDraftResultProfile,
openLibraryDetail,
persistAgentUiState,
releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle,
savedCustomWorldEntries,
setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState,
setCustomWorldError,
setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource,
setGeneratedCustomWorldProfile,
setPlatformError,
setPlatformTabToCreate,
setSavedCustomWorldEntries,
setSelectionStage,
suppressAgentDraftResultAutoOpen,
syncAgentSessionSnapshot,
userId,
],
);
const handlePublishSelectedWorld = useCallback(async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const mutation = await publishRpgEntryWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(mutation.entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(mutation.entry);
await refreshPublishedGallery().catch(() => []);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '发布自定义世界失败。'),
);
} finally {
setIsMutatingDetail(false);
}
}, [
isMutatingDetail,
refreshCustomWorldWorks,
refreshPublishedGallery,
selectedDetailEntry,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
]);
const handleUnpublishSelectedWorld = useCallback(async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const mutation = await unpublishRpgEntryWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(mutation.entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(mutation.entry);
await refreshPublishedGallery().catch(() => []);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '下架自定义世界失败。'),
);
} finally {
setIsMutatingDetail(false);
}
}, [
isMutatingDetail,
refreshCustomWorldWorks,
refreshPublishedGallery,
selectedDetailEntry,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
]);
const handleDeleteSelectedWorld = useCallback(async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
const confirmed = window.confirm(
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const entries = await deleteRpgEntryWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(null);
setPlatformTabToCreate();
setSelectionStage('platform');
await refreshPublishedGallery().catch(() => []);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '删除自定义世界失败。'),
);
} finally {
setIsMutatingDetail(false);
}
}, [
isMutatingDetail,
refreshCustomWorldWorks,
refreshPublishedGallery,
selectedDetailEntry,
setPlatformTabToCreate,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
setSelectionStage,
]);
const isSelectedWorldOwned = Boolean(
selectedDetailEntry &&
savedCustomWorldEntries.some(
(entry) =>
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
entry.profileId === selectedDetailEntry.profileId,
),
);
return {
detailError,
setDetailError,
isDetailLoading,
isMutatingDetail,
isSelectedWorldOwned,
openLibraryDetail,
openGalleryDetail,
openSavedCustomWorldEditor,
handleOpenCreationWork,
handlePublishSelectedWorld,
handleUnpublishSelectedWorld,
handleDeleteSelectedWorld,
};
}
/**
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
*/
export const useRpgCreationDetailNavigation = useRpgEntryLibraryDetail;

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import type { SelectionStage } from './rpgEntryTypes';
type UseRpgEntryNavigationParams = {
setSelectionStage: (stage: SelectionStage) => void;
setSelectedDetailEntry: (
entry: CustomWorldLibraryEntry<CustomWorldProfile> | null,
) => void;
};
/**
* 收口 RPG 入口壳层的阶段跳转,避免页面内散落大量匿名 setState 回调。
*/
export function useRpgEntryNavigation(
params: UseRpgEntryNavigationParams,
) {
const { setSelectionStage, setSelectedDetailEntry } = params;
const backToPlatformHome = useCallback(() => {
setSelectionStage('platform');
}, [setSelectionStage]);
const backToPlatformWithClearedDetail = useCallback(() => {
setSelectedDetailEntry(null);
setSelectionStage('platform');
}, [setSelectedDetailEntry, setSelectionStage]);
const openDetailStage = useCallback(() => {
setSelectionStage('detail');
}, [setSelectionStage]);
const openAgentWorkspaceStage = useCallback(() => {
setSelectionStage('agent-workspace');
}, [setSelectionStage]);
const openCustomWorldGenerationStage = useCallback(() => {
setSelectionStage('custom-world-generating');
}, [setSelectionStage]);
const openCustomWorldResultStage = useCallback(() => {
setSelectionStage('custom-world-result');
}, [setSelectionStage]);
return {
backToPlatformHome,
backToPlatformWithClearedDetail,
openDetailStage,
openAgentWorkspaceStage,
openCustomWorldGenerationStage,
openCustomWorldResultStage,
};
}

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
type UseRpgEntrySaveResumeParams = {
handleResumeSaveEntry: (entry: ProfileSaveArchiveSummary) => Promise<void>;
};
/**
* RPG 入口域里的“继续游戏”入口只负责转发恢复动作,
* 让壳层组件不直接知道具体的存档恢复实现细节。
*/
export function useRpgEntrySaveResume(
params: UseRpgEntrySaveResumeParams,
) {
const { handleResumeSaveEntry } = params;
const resumeSelectedSave = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
await handleResumeSaveEntry(entry);
},
[handleResumeSaveEntry],
);
return {
resumeSelectedSave,
};
}

View File

@@ -1,8 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { AdventurePanel } from './AdventurePanel';
import { type Character, type StoryMoment, WorldType } from '../types';
import { type Character, type StoryMoment, WorldType } from '../../types';
import { RpgAdventurePanel } from './RpgAdventurePanel';
function createCharacter(): Character {
return {
@@ -39,7 +39,7 @@ test('adventure panel renders system turns without special relationship labels',
};
const html = renderToStaticMarkup(
<AdventurePanel
<RpgAdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
@@ -58,7 +58,7 @@ test('adventure panel renders system turns without special relationship labels',
claimQuestReward: () => null,
}}
npcChatQuestOfferUi={{
replacePendingOffer: async () => false,
replacePendingOffer: () => false,
abandonPendingOffer: () => false,
acceptPendingOffer: () => null,
}}
@@ -130,7 +130,7 @@ test('adventure panel shows current act label and remaining turns for limited ho
};
const html = renderToStaticMarkup(
<AdventurePanel
<RpgAdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
@@ -151,7 +151,7 @@ test('adventure panel shows current act label and remaining turns for limited ho
claimQuestReward: () => null,
}}
npcChatQuestOfferUi={{
replacePendingOffer: async () => false,
replacePendingOffer: () => false,
abandonPendingOffer: () => false,
acceptPendingOffer: () => null,
}}

View File

@@ -1,8 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
import { AdventurePanel } from './AdventurePanel';
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { RpgAdventurePanel } from './RpgAdventurePanel';
function createCharacter(): Character {
return {
@@ -55,7 +55,7 @@ function renderPanel(
} = {},
) {
return renderToStaticMarkup(
<AdventurePanel
<RpgAdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={overrides.isLoading ?? false}
@@ -76,7 +76,7 @@ function renderPanel(
claimQuestReward: () => null,
}}
npcChatQuestOfferUi={{
replacePendingOffer: async () => false,
replacePendingOffer: () => false,
abandonPendingOffer: () => false,
acceptPendingOffer: () => null,
}}

View File

@@ -16,23 +16,23 @@ import {
import { motion } from 'motion/react';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { formatCurrency } from '../data/economy';
import { getEquipmentSlotFromItem } from '../data/equipmentEffects';
import { formatCurrency } from '../../data/economy';
import { getEquipmentSlotFromItem } from '../../data/equipmentEffects';
import {
getFunctionDocumentationById,
isContinueAdventureOption,
NPC_CHAT_FUNCTION,
} from '../data/functionCatalog';
import { getHostileNpcPresetById } from '../data/hostileNpcPresets';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { isQuestReadyToClaim } from '../data/questFlow';
import { getScenePresetById } from '../data/scenePresets';
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
} from '../../data/functionCatalog';
import { getHostileNpcPresetById } from '../../data/hostileNpcPresets';
import { resolveInventoryItemUseEffect } from '../../data/inventoryEffects';
import { isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import { getOptionImpactSummary } from '../../hooks/combatStoryUtils';
import type {
BattleRewardUi,
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../hooks/useStoryGeneration';
} from '../../hooks/rpg-runtime-story';
import type {
ChapterState,
Character,
@@ -46,18 +46,18 @@ import type {
StoryMoment,
StoryOption,
WorldType,
} from '../types';
} from '../../types';
import {
CHROME_ICONS,
getInventoryCategoryIcon,
getNineSliceStyle,
TAB_ICONS,
UI_CHROME,
} from '../uiAssets';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { PixelIcon } from './PixelIcon';
} from '../../uiAssets';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { PixelIcon } from '../PixelIcon';
interface AdventurePanelProps {
export interface RpgAdventurePanelProps {
aiError: string | null;
currentStory: StoryMoment;
isLoading: boolean;
@@ -115,11 +115,11 @@ interface AdventurePanelProps {
currentSceneActCount?: number | null;
}
const AdventurePanelOverlays = lazy(async () => {
const module = await import('./adventure-panel/AdventurePanelOverlays');
const RpgAdventurePanelOverlays = lazy(async () => {
const module = await import('./RpgAdventurePanelOverlays');
return {
default: module.AdventurePanelOverlays,
default: module.RpgAdventurePanelOverlays,
};
});
@@ -214,14 +214,14 @@ function getDialogueTurnLabel(
}
if (turn.speaker === 'player') {
return '\u4f60';
return '';
}
if (turn.speaker === 'companion') {
return turn.speakerName?.trim() || '\u540c\u4f34';
return turn.speakerName?.trim() || '同伴';
}
return turn.speakerName?.trim() || '\u5bf9\u65b9';
return turn.speakerName?.trim() || '对方';
}
function getQuestRewardItemIcon(item: InventoryItem) {
@@ -596,26 +596,477 @@ function QuestObjectiveCard({
</div>
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-3">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.18em] text-zinc-500">
<span>Progress</span>
<span className="text-white/70">
<div className="flex items-center justify-between gap-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
</div>
<div className="text-xs text-zinc-400">
{quest.progress}/{quest.objective.requiredCount}
</span>
</div>
</div>
<div className="mt-3">
<QuestProgressPips
progress={quest.progress}
total={quest.objective.requiredCount}
activeClassName="border-emerald-300/30 bg-emerald-400/85"
activeClassName="border-amber-200/40 bg-amber-400/75"
/>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
</div>
<div className="mt-2 text-sm leading-6 text-zinc-100">
{quest.description}
</div>
</div>
</div>
</div>
</div>
);
}
function RpgAdventureStorySection(props: {
currentSceneActTitle: string | null;
currentSceneActIndex: number | null;
currentSceneActCount: number | null;
limitedNpcChatRemainingTurns: number | null;
storyScrollContainerRef: React.RefObject<HTMLDivElement | null>;
isDialogueStory: boolean;
dialogueTurns: NonNullable<StoryMoment['dialogue']>;
isNpcChatMode: boolean;
isStoryStreaming: boolean;
currentStory: StoryMoment;
}) {
const {
currentSceneActTitle,
currentSceneActIndex,
currentSceneActCount,
limitedNpcChatRemainingTurns,
storyScrollContainerRef,
isDialogueStory,
dialogueTurns,
isNpcChatMode,
isStoryStreaming,
currentStory,
} = props;
return (
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
<div className="mb-3 flex flex-wrap items-center gap-2 px-1">
{currentSceneActTitle ? (
<div className="inline-flex items-center gap-2 rounded-full border border-sky-300/18 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
<span></span>
<span className="text-white/90">{currentSceneActTitle}</span>
{currentSceneActIndex && currentSceneActCount ? (
<span className="text-sky-100/65">
{currentSceneActIndex}/{currentSceneActCount}
</span>
) : null}
</div>
) : null}
{limitedNpcChatRemainingTurns !== null ? (
<div className="inline-flex items-center gap-2 rounded-full border border-rose-300/18 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-rose-100">
<span></span>
<span className="text-white/90">
{limitedNpcChatRemainingTurns}
</span>
</div>
) : null}
</div>
)}
{isDialogueStory ? (
<div className="space-y-3">
{dialogueTurns.length > 0 ? (
dialogueTurns.map((turn, index) => (
<div
key={`${turn.speaker}-${turn.speakerName ?? 'default'}-${index}-${turn.text}`}
className={`flex ${getDialogueTurnAlignmentClass(turn)}`}
>
<div
className={`max-w-[88%] border px-3 py-2 text-sm leading-relaxed ${getDialogueTurnBubbleClass(turn)} ${getDialogueTurnBubbleShapeClass(turn)}`}
>
<div className="sr-only">
{turn.speaker === 'player' ? '你' : '对方'}
</div>
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
{getDialogueTurnLabel(turn)}
</div>
{turn.text}
</div>
</div>
))
) : isNpcChatMode && !isStoryStreaming ? (
<div className="h-1" aria-hidden="true" />
) : (
<div className="flex justify-start">
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-zinc-400">
...
</div>
</div>
)}
</div>
) : (
<p className="font-serif text-[15px] leading-7 text-zinc-200 sm:text-base">
{currentStory.text}
</p>
)}
</div>
);
}
function RpgAdventureChoiceSection(props: {
isNpcChatMode: boolean;
canRefreshOptions: boolean;
shouldHideChoiceUi: boolean;
onRefreshOptions: () => void;
onOpenCharacter: () => void;
onOpenInventory: () => void;
onExitNpcChat?: () => boolean;
isLoading: boolean;
isStoryStreaming: boolean;
displayedOptions: StoryOption[];
playerCharacter: Character;
playerHp: number;
playerMaxHp: number;
playerMana: number;
playerMaxMana: number;
playerSkillCooldowns: Record<string, number>;
currentNpcBattleMode: NpcBattleMode | null;
hasDeferredAdventureOptions: boolean;
handleOptionChoice: (option: StoryOption) => void;
isNpcQuestOfferMode: boolean;
npcChatDraft: string;
setNpcChatDraft: (value: string) => void;
npcChatPlaceholder: string;
submitNpcChatDraft: () => void;
}) {
const {
isNpcChatMode,
canRefreshOptions,
shouldHideChoiceUi,
onRefreshOptions,
onOpenCharacter,
onOpenInventory,
onExitNpcChat,
isLoading,
isStoryStreaming,
displayedOptions,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
hasDeferredAdventureOptions,
handleOptionChoice,
isNpcQuestOfferMode,
npcChatDraft,
setNpcChatDraft,
npcChatPlaceholder,
submitNpcChatDraft,
} = props;
return (
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
onClick={onOpenCharacter}
aria-label="打开队伍"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon src={TAB_ICONS.character.active} className="h-4 w-4" />
<span className="text-xs leading-none"></span>
</button>
<button
type="button"
onClick={onOpenInventory}
aria-label="打开背包"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon src={TAB_ICONS.inventory.active} className="h-4 w-4" />
<span className="text-xs leading-none"></span>
</button>
</div>
{isNpcChatMode ? (
<button
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
) : canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : null}
</div>
<div className="space-y-1.5">
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs uppercase tracking-widest">
...
</span>
</div>
) : isStoryStreaming ? (
<div className="flex items-center justify-center p-4 text-[11px] tracking-[0.18em] text-zinc-500">
</div>
) : shouldHideChoiceUi ? (
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
);
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
if (isDeferredContinueOption) {
return (
<motion.button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
return (
<button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
onClick={() => handleOptionChoice(option)}
disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && option.goalAffordance?.label && (
<div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode && optionImpactSummary && !optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => setNpcChatDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submitNpcChatDraft();
}
}}
placeholder={npcChatPlaceholder}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
</div>
</div>
) : null}
</>
)}
</div>
</div>
);
}
function RpgAdventureOverlaySection(props: {
shouldMountAdventureOverlays: boolean;
worldType: WorldType | null;
quests: QuestLogEntry[];
questUi: QuestFlowUi;
battleRewardUi: BattleRewardUi;
statistics: RpgAdventurePanelProps['statistics'];
statisticsCards: Array<{
key: string;
label: string;
value: string;
detail: string;
icon: typeof Clock3;
}>;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isGoalPanelOpen: boolean;
setIsGoalPanelOpen: (value: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (value: boolean) => void;
isSettingsPanelOpen: boolean;
setIsSettingsPanelOpen: (value: boolean) => void;
isStatsPanelOpen: boolean;
setIsStatsPanelOpen: (value: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
onDismissGoalPulse: () => void;
selectedQuest: QuestLogEntry | null;
setSelectedQuestId: (questId: string | null) => void;
completionNoticeQuest: QuestLogEntry | null;
setCompletionNoticeQuestId: (questId: string | null) => void;
rewardQuest: QuestLogEntry | null;
setRewardQuestId: (questId: string | null) => void;
rewardQuestHandoff: GoalHandoff | null;
setRewardQuestHandoff: (handoff: GoalHandoff | null) => void;
selectedRewardItemQuestId: string | null;
setSelectedRewardItemQuestId: (questId: string | null) => void;
selectedRewardItemId: string | null;
setSelectedRewardItemId: (itemId: string | null) => void;
selectedBattleRewardItemId: string | null;
setSelectedBattleRewardItemId: (itemId: string | null) => void;
selectedRewardItem: InventoryItem | null;
selectedRewardUseEffect: ReturnType<typeof resolveInventoryItemUseEffect> | null;
selectedRewardEquipSlot: ReturnType<typeof getEquipmentSlotFromItem> | null;
selectedQuestSceneName: string;
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
pendingNpcQuestOffer: QuestLogEntry | null;
onAcceptPendingNpcQuestOffer: () => string | null;
}) {
if (!props.shouldMountAdventureOverlays) {
return null;
}
return (
<Suspense fallback={<AdventurePanelOverlayLoadingFallback />}>
<RpgAdventurePanelOverlays
worldType={props.worldType}
quests={props.quests}
questUi={props.questUi}
battleRewardUi={props.battleRewardUi}
statistics={props.statistics}
statisticsCards={props.statisticsCards}
musicVolume={props.musicVolume}
onMusicVolumeChange={props.onMusicVolumeChange}
onSaveAndExit={props.onSaveAndExit}
saveAndExitDisabled={props.saveAndExitDisabled}
isGoalPanelOpen={props.isGoalPanelOpen}
setIsGoalPanelOpen={props.setIsGoalPanelOpen}
isQuestPanelOpen={props.isQuestPanelOpen}
setIsQuestPanelOpen={props.setIsQuestPanelOpen}
isSettingsPanelOpen={props.isSettingsPanelOpen}
setIsSettingsPanelOpen={props.setIsSettingsPanelOpen}
isStatsPanelOpen={props.isStatsPanelOpen}
setIsStatsPanelOpen={props.setIsStatsPanelOpen}
chapterState={props.chapterState}
journeyBeat={props.journeyBeat}
goalStack={props.goalStack}
goalPulse={props.goalPulse}
onDismissGoalPulse={props.onDismissGoalPulse}
selectedQuest={props.selectedQuest}
setSelectedQuestId={props.setSelectedQuestId}
completionNoticeQuest={props.completionNoticeQuest}
setCompletionNoticeQuestId={props.setCompletionNoticeQuestId}
rewardQuest={props.rewardQuest}
setRewardQuestId={props.setRewardQuestId}
rewardQuestHandoff={props.rewardQuestHandoff}
setRewardQuestHandoff={props.setRewardQuestHandoff}
selectedRewardItemQuestId={props.selectedRewardItemQuestId}
setSelectedRewardItemQuestId={props.setSelectedRewardItemQuestId}
selectedRewardItemId={props.selectedRewardItemId}
setSelectedRewardItemId={props.setSelectedRewardItemId}
selectedBattleRewardItemId={props.selectedBattleRewardItemId}
setSelectedBattleRewardItemId={props.setSelectedBattleRewardItemId}
selectedRewardItem={props.selectedRewardItem}
selectedRewardUseEffect={props.selectedRewardUseEffect}
selectedRewardEquipSlot={props.selectedRewardEquipSlot}
selectedQuestSceneName={props.selectedQuestSceneName}
getQuestStatusLabel={props.getQuestStatusLabel}
pendingNpcQuestOffer={props.pendingNpcQuestOffer}
onAcceptPendingNpcQuestOffer={props.onAcceptPendingNpcQuestOffer}
/>
</Suspense>
);
}
const LEGACY_ADVENTURE_PANEL_HELPERS = {
getQuestRewardItemIcon,
getRewardItemFrameClass,
@@ -629,7 +1080,12 @@ const LEGACY_ADVENTURE_PANEL_HELPERS = {
void LEGACY_ADVENTURE_PANEL_HELPERS;
export function AdventurePanel({
/**
* RPG
* `rpg-runtime-panels`
* story / choice / overlay section
*/
export function RpgAdventurePanel({
aiError,
currentStory,
isLoading,
@@ -666,7 +1122,7 @@ export function AdventurePanel({
currentSceneActTitle = null,
currentSceneActIndex = null,
currentSceneActCount = null,
}: AdventurePanelProps) {
}: RpgAdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
const npcChatState = currentStory.npcChatState ?? null;
@@ -1045,312 +1501,102 @@ export function AdventurePanel({
</div>
)}
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
<div className="mb-3 flex flex-wrap items-center gap-2 px-1">
{currentSceneActTitle ? (
<div className="inline-flex items-center gap-2 rounded-full border border-sky-300/18 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
<span></span>
<span className="text-white/90">{currentSceneActTitle}</span>
{currentSceneActIndex && currentSceneActCount ? (
<span className="text-sky-100/65">
{currentSceneActIndex}/{currentSceneActCount}
</span>
) : null}
</div>
) : null}
{limitedNpcChatRemainingTurns !== null ? (
<div className="inline-flex items-center gap-2 rounded-full border border-rose-300/18 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-rose-100">
<span></span>
<span className="text-white/90">
{limitedNpcChatRemainingTurns}
</span>
</div>
) : null}
</div>
)}
{isDialogueStory ? (
<div className="space-y-3">
{dialogueTurns.length > 0 ? (
dialogueTurns.map((turn, index) => (
<div
key={`${turn.speaker}-${turn.speakerName ?? 'default'}-${index}-${turn.text}`}
className={`flex ${getDialogueTurnAlignmentClass(turn)}`}
>
<div
className={`max-w-[88%] border px-3 py-2 text-sm leading-relaxed ${getDialogueTurnBubbleClass(turn)} ${getDialogueTurnBubbleShapeClass(turn)}`}
>
<div className="sr-only">
{turn.speaker === 'player' ? '你' : '对方'}
</div>
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
{getDialogueTurnLabel(turn)}
</div>
{turn.text}
</div>
</div>
))
) : isNpcChatMode && !isStoryStreaming ? (
<div className="h-1" aria-hidden="true" />
) : (
<div className="flex justify-start">
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-zinc-400">
...
</div>
</div>
)}
</div>
) : (
<p className="font-serif text-[15px] leading-7 text-zinc-200 sm:text-base">
{currentStory.text}
</p>
)}
</div>
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
onClick={onOpenCharacter}
aria-label="打开队伍"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon src={TAB_ICONS.character.active} className="h-4 w-4" />
<span className="text-xs leading-none"></span>
</button>
<button
type="button"
onClick={onOpenInventory}
aria-label="打开背包"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon src={TAB_ICONS.inventory.active} className="h-4 w-4" />
<span className="text-xs leading-none"></span>
</button>
</div>
<RpgAdventureChoiceSection
isNpcChatMode={isNpcChatMode}
canRefreshOptions={canRefreshOptions}
shouldHideChoiceUi={shouldHideChoiceUi}
onRefreshOptions={onRefreshOptions}
onOpenCharacter={onOpenCharacter}
onOpenInventory={onOpenInventory}
onExitNpcChat={onExitNpcChat}
isLoading={isLoading}
isStoryStreaming={isStoryStreaming}
displayedOptions={displayedOptions}
playerCharacter={playerCharacter}
playerHp={playerHp}
playerMaxHp={playerMaxHp}
playerMana={playerMana}
playerMaxMana={playerMaxMana}
playerSkillCooldowns={playerSkillCooldowns}
currentNpcBattleMode={currentNpcBattleMode}
hasDeferredAdventureOptions={hasDeferredAdventureOptions}
handleOptionChoice={handleOptionChoice}
isNpcQuestOfferMode={isNpcQuestOfferMode}
npcChatDraft={npcChatDraft}
setNpcChatDraft={setNpcChatDraft}
npcChatPlaceholder={
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
submitNpcChatDraft={submitNpcChatDraft}
/>
{isNpcChatMode ? (
<button
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
) : canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : null}
</div>
<div className="space-y-1.5">
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs uppercase tracking-widest">
...
</span>
</div>
) : isStoryStreaming ? (
<div className="flex items-center justify-center p-4 text-[11px] tracking-[0.18em] text-zinc-500">
</div>
) : shouldHideChoiceUi ? (
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
);
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
if (isDeferredContinueOption) {
return (
<motion.button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
return (
<button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
onClick={() => handleOptionChoice(option)}
disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && option.goalAffordance?.label && (
<div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode &&
optionImpactSummary &&
!optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => setNpcChatDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submitNpcChatDraft();
}
}}
placeholder={
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
</div>
</div>
) : null}
</>
)}
</div>
</div>
{shouldMountAdventureOverlays && (
<Suspense fallback={<AdventurePanelOverlayLoadingFallback />}>
<AdventurePanelOverlays
worldType={worldType}
quests={quests}
questUi={questUi}
battleRewardUi={battleRewardUi}
statistics={statistics}
statisticsCards={statisticsCards}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
saveAndExitDisabled={saveAndExitDisabled}
isGoalPanelOpen={isGoalPanelOpen}
setIsGoalPanelOpen={setIsGoalPanelOpen}
isQuestPanelOpen={isQuestPanelOpen}
setIsQuestPanelOpen={setIsQuestPanelOpen}
isSettingsPanelOpen={isSettingsPanelOpen}
setIsSettingsPanelOpen={setIsSettingsPanelOpen}
isStatsPanelOpen={isStatsPanelOpen}
setIsStatsPanelOpen={setIsStatsPanelOpen}
chapterState={chapterState}
journeyBeat={journeyBeat}
goalStack={goalStack}
goalPulse={goalPulse}
onDismissGoalPulse={handleDismissGoalPanel}
selectedQuest={selectedQuest}
setSelectedQuestId={setSelectedQuestId}
completionNoticeQuest={completionNoticeQuest}
setCompletionNoticeQuestId={setCompletionNoticeQuestId}
rewardQuest={rewardQuest}
setRewardQuestId={setRewardQuestId}
rewardQuestHandoff={rewardQuestHandoff}
setRewardQuestHandoff={setRewardQuestHandoff}
selectedRewardItemQuestId={selectedRewardItemQuestId}
setSelectedRewardItemQuestId={setSelectedRewardItemQuestId}
selectedRewardItemId={selectedRewardItemId}
setSelectedRewardItemId={setSelectedRewardItemId}
selectedBattleRewardItemId={selectedBattleRewardItemId}
setSelectedBattleRewardItemId={setSelectedBattleRewardItemId}
selectedRewardItem={selectedRewardItem}
selectedRewardUseEffect={selectedRewardUseEffect}
selectedRewardEquipSlot={selectedRewardEquipSlot}
selectedQuestSceneName={selectedQuestSceneName}
getQuestStatusLabel={getQuestStatusLabel}
pendingNpcQuestOffer={pendingNpcQuestOffer}
onAcceptPendingNpcQuestOffer={() => {
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
if (!acceptedQuestId) return null;
setSelectedQuestId(null);
return acceptedQuestId;
}}
/>
</Suspense>
)}
<RpgAdventureOverlaySection
shouldMountAdventureOverlays={shouldMountAdventureOverlays}
worldType={worldType}
quests={quests}
questUi={questUi}
battleRewardUi={battleRewardUi}
statistics={statistics}
statisticsCards={statisticsCards}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
saveAndExitDisabled={saveAndExitDisabled}
isGoalPanelOpen={isGoalPanelOpen}
setIsGoalPanelOpen={setIsGoalPanelOpen}
isQuestPanelOpen={isQuestPanelOpen}
setIsQuestPanelOpen={setIsQuestPanelOpen}
isSettingsPanelOpen={isSettingsPanelOpen}
setIsSettingsPanelOpen={setIsSettingsPanelOpen}
isStatsPanelOpen={isStatsPanelOpen}
setIsStatsPanelOpen={setIsStatsPanelOpen}
chapterState={chapterState}
journeyBeat={journeyBeat}
goalStack={goalStack}
goalPulse={goalPulse}
onDismissGoalPulse={handleDismissGoalPanel}
selectedQuest={selectedQuest}
setSelectedQuestId={setSelectedQuestId}
completionNoticeQuest={completionNoticeQuest}
setCompletionNoticeQuestId={setCompletionNoticeQuestId}
rewardQuest={rewardQuest}
setRewardQuestId={setRewardQuestId}
rewardQuestHandoff={rewardQuestHandoff}
setRewardQuestHandoff={setRewardQuestHandoff}
selectedRewardItemQuestId={selectedRewardItemQuestId}
setSelectedRewardItemQuestId={setSelectedRewardItemQuestId}
selectedRewardItemId={selectedRewardItemId}
setSelectedRewardItemId={setSelectedRewardItemId}
selectedBattleRewardItemId={selectedBattleRewardItemId}
setSelectedBattleRewardItemId={setSelectedBattleRewardItemId}
selectedRewardItem={selectedRewardItem}
selectedRewardUseEffect={selectedRewardUseEffect}
selectedRewardEquipSlot={selectedRewardEquipSlot}
selectedQuestSceneName={selectedQuestSceneName}
getQuestStatusLabel={getQuestStatusLabel}
pendingNpcQuestOffer={pendingNpcQuestOffer}
onAcceptPendingNpcQuestOffer={() => {
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
if (!acceptedQuestId) return null;
setSelectedQuestId(null);
return acceptedQuestId;
}}
/>
</div>
);
}
export default RpgAdventurePanel;

View File

@@ -27,7 +27,7 @@ import { getScenePresetById } from '../../data/scenePresets';
import type {
BattleRewardUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
} from '../../hooks/rpg-runtime-story';
import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector';
import type {
ChapterState,
@@ -73,7 +73,7 @@ type AdventureStatistics = {
rosterCompanionCount: number;
};
interface AdventurePanelOverlaysProps {
interface RpgAdventurePanelOverlaysProps {
worldType: WorldType | null;
quests: QuestLogEntry[];
questUi: QuestFlowUi;
@@ -864,7 +864,7 @@ function QuestObjectiveCard({
);
}
export function AdventurePanelOverlays({
export function RpgAdventurePanelOverlays({
worldType,
quests,
questUi,
@@ -909,7 +909,7 @@ export function AdventurePanelOverlays({
getQuestStatusLabel,
pendingNpcQuestOffer,
onAcceptPendingNpcQuestOffer,
}: AdventurePanelOverlaysProps) {
}: RpgAdventurePanelOverlaysProps) {
const battleReward = battleRewardUi.reward;
const sortedQuests = sortQuestsForGoalPanel(quests, goalStack);
const activeGoalQuest =

View File

@@ -1,6 +1,6 @@
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/useGameFlow';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -8,7 +8,7 @@ import type {
InventoryFlowUi,
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
} from '../../hooks/rpg-runtime-story';
import type {
CompanionRenderState,
GameState,
@@ -18,14 +18,16 @@ import type {
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { PanelLoadingFallback } from './GameShellLoaders';
import type { GameShellAdventureStatistics } from './types';
import {
PanelLoadingFallback,
type RpgAdventureStatistics,
} from '../rpg-runtime-shell';
const AdventurePanel = lazy(async () => {
const module = await import('../AdventurePanel');
const RpgAdventurePanel = lazy(async () => {
const module = await import('./RpgAdventurePanel');
return {
default: module.AdventurePanel,
default: module.RpgAdventurePanel,
};
});
@@ -45,7 +47,42 @@ const InventoryPanel = lazy(async () => {
};
});
export function GameShellStoryPanels({
export interface RpgRuntimePanelRouterProps {
visibleGameState: GameState;
visibleCurrentStory: StoryMoment;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
displayedOptions: StoryOption[];
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: RpgAdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
}
/**
* RPG
* / /
*/
export function RpgRuntimePanelRouter({
visibleGameState,
visibleCurrentStory,
isLoading,
@@ -74,36 +111,7 @@ export function GameShellStoryPanels({
musicVolume,
onMusicVolumeChange,
onSaveAndExit,
}: {
visibleGameState: GameState;
visibleCurrentStory: StoryMoment;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
displayedOptions: StoryOption[];
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: GameShellAdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
}) {
}: RpgRuntimePanelRouterProps) {
const playerCharacter = visibleGameState.playerCharacter;
if (!playerCharacter) {
return null;
@@ -212,7 +220,7 @@ export function GameShellStoryPanels({
{bottomTab === 'adventure' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
<AdventurePanel
<RpgAdventurePanel
aiError={aiError}
currentStory={visibleCurrentStory}
isLoading={isLoading}
@@ -286,3 +294,5 @@ export function GameShellStoryPanels({
</>
);
}
export default RpgRuntimePanelRouter;

View File

@@ -0,0 +1,8 @@
export {
RpgAdventurePanel,
type RpgAdventurePanelProps,
} from './RpgAdventurePanel';
export {
RpgRuntimePanelRouter,
type RpgRuntimePanelRouterProps,
} from './RpgRuntimePanelRouter';

View File

@@ -1,12 +1,29 @@
import type {
CompanionRenderState,
GameState,
} from '../../types';
import type { CompanionRenderState, GameState } from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { GameShellDialogueIndicator } from './types';
import { GameCanvas } from '../GameCanvas';
import type { RpgRuntimeDialogueIndicator } from './types';
export function GameShellCanvasStage({
export interface RpgRuntimeCanvasStageProps {
gameState: GameState;
visibleGameState: GameState;
hideSelectionHero: boolean;
canvasCompanionRenderStates: CompanionRenderState[];
dialogueIndicator: RpgRuntimeDialogueIndicator | null;
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
sceneTransitionToken: number;
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
setIsMapOpen: (open: boolean) => void;
setSceneTransitionDurations: (durations: {
exitMs: number;
entryMs: number;
}) => void;
}
/**
* RPG
* `GameShellCanvasStage`
*/
export function RpgRuntimeCanvasStage({
gameState,
visibleGameState,
hideSelectionHero,
@@ -17,20 +34,11 @@ export function GameShellCanvasStage({
setSelectedSceneEntity,
setIsMapOpen,
setSceneTransitionDurations,
}: {
gameState: GameState;
visibleGameState: GameState;
hideSelectionHero: boolean;
canvasCompanionRenderStates: CompanionRenderState[];
dialogueIndicator: GameShellDialogueIndicator | null;
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
sceneTransitionToken: number;
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
setIsMapOpen: (open: boolean) => void;
setSceneTransitionDurations: (durations: { exitMs: number; entryMs: number }) => void;
}) {
}: RpgRuntimeCanvasStageProps) {
return (
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
<div
className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}
>
{hideSelectionHero ? null : (
<GameCanvas
scrollWorld={visibleGameState.scrollWorld}
@@ -65,3 +73,5 @@ export function GameShellCanvasStage({
</div>
);
}
export default RpgRuntimeCanvasStage;

View File

@@ -5,12 +5,15 @@ import type {
CharacterChatUi,
InventoryFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
} from '../../hooks/rpg-runtime-story';
import type { CompanionRenderState, GameState } from '../../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { ModalLoadingFallback, PanelLoadingFallback } from './GameShellLoaders';
import {
ModalLoadingFallback,
PanelLoadingFallback,
} from './rpgRuntimeLoaders';
const AdventureEntityModal = lazy(async () => {
const module = await import('../AdventureEntityModal');
@@ -68,33 +71,7 @@ const InventoryPanel = lazy(async () => {
};
});
export function GameShellOverlays({
gameState,
isLoading,
isMapOpen,
setIsMapOpen,
npcUi,
characterChatUi,
inventoryUi,
companionRenderStates,
characterChatSummaries,
overlayPanel,
closeOverlayPanel,
openCampModal,
openPartyMemberDetails,
shouldMountAdventureEntityModal,
selectedSceneEntity,
closeAdventureEntityModal,
shouldMountCampModal,
showTeamModal,
closeCampModal,
onBenchCompanion,
onActivateRosterCompanion,
shouldMountMapModal,
handleMapTravelToScene,
shouldMountCharacterChatModal,
shouldMountNpcModals,
}: {
export interface RpgRuntimeOverlayHostProps {
gameState: GameState;
isLoading: boolean;
isMapOpen: boolean;
@@ -120,7 +97,39 @@ export function GameShellOverlays({
handleMapTravelToScene: (sceneId: string) => boolean;
shouldMountCharacterChatModal: boolean;
shouldMountNpcModals: boolean;
}) {
}
/**
* RPG overlay host
* RPG
*/
export function RpgRuntimeOverlayHost({
gameState,
isLoading,
isMapOpen,
setIsMapOpen,
npcUi,
characterChatUi,
inventoryUi,
companionRenderStates,
characterChatSummaries,
overlayPanel,
closeOverlayPanel,
openCampModal,
openPartyMemberDetails,
shouldMountAdventureEntityModal,
selectedSceneEntity,
closeAdventureEntityModal,
shouldMountCampModal,
showTeamModal,
closeCampModal,
onBenchCompanion,
onActivateRosterCompanion,
shouldMountMapModal,
handleMapTravelToScene,
shouldMountCharacterChatModal,
shouldMountNpcModals,
}: RpgRuntimeOverlayHostProps) {
return (
<>
{shouldMountAdventureEntityModal && (
@@ -222,6 +231,15 @@ export function GameShellOverlays({
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
gameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
gameState.storyEngineMemory?.narrativeCodex ?? []
}
narrativeQaReport={
gameState.storyEngineMemory?.narrativeQaReport ?? null
}
/>
</Suspense>
)}
@@ -309,3 +327,5 @@ export function GameShellOverlays({
</>
);
}
export default RpgRuntimeOverlayHost;

View File

@@ -3,30 +3,30 @@ import { lazy, Suspense } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import { UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { GameShellMainContent } from './GameShellMainContent';
import type { GameShellProps } from './types';
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
import { RpgRuntimeCanvasStage } from './RpgRuntimeCanvasStage';
import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types';
import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter';
import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel';
const GameShellOverlays = lazy(async () => {
const module = await import('./GameShellOverlays');
const RpgRuntimeOverlayHost = lazy(async () => {
const module = await import('./RpgRuntimeOverlayHost');
return {
default: module.GameShellOverlays,
};
});
const GameShellCanvasStage = lazy(async () => {
const module = await import('./GameShellCanvasStage');
return {
default: module.GameShellCanvasStage,
default: module.RpgRuntimeOverlayHost,
};
});
export function GameShellRuntime({
/**
* RPG
* overlay host
* UI RPG
*/
export function RpgRuntimeShell({
session,
story,
entry,
companions,
audio,
}: GameShellProps) {
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
@@ -102,7 +102,7 @@ export function GameShellRuntime({
canvasCompanionRenderStates,
adventureStatistics,
handleSceneTransitionChoice,
} = useGameShellRuntimeViewModel({
} = useRpgRuntimeShellViewModel({
session,
story,
companions,
@@ -133,7 +133,7 @@ export function GameShellRuntime({
}}
>
<Suspense fallback={null}>
<GameShellCanvasStage
<RpgRuntimeCanvasStage
gameState={gameState}
visibleGameState={visibleGameState}
hideSelectionHero={hideSelectionHero}
@@ -177,7 +177,7 @@ export function GameShellRuntime({
</div>
)}
<GameShellMainContent
<RpgRuntimeStageRouter
gameState={gameState}
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
@@ -221,7 +221,7 @@ export function GameShellRuntime({
/>
<Suspense fallback={null}>
<GameShellOverlays
<RpgRuntimeOverlayHost
gameState={gameState}
isLoading={isLoading}
isMapOpen={isMapOpen}
@@ -252,3 +252,7 @@ export function GameShellRuntime({
</div>
);
}
export type RpgRuntimeShellProps = RpgRuntimeShellComponentProps;
export default RpgRuntimeShell;

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/useGameFlow';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -9,7 +9,7 @@ import type {
InventoryFlowUi,
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
} from '../../hooks/rpg-runtime-story';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
CompanionRenderState,
@@ -20,25 +20,27 @@ import type {
} from '../../types';
import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from './PreGameSelectionFlow';
import type { GameShellAdventureStatistics } from './types';
import type { SelectionStage } from '../rpg-entry';
import type { RpgAdventureStatistics } from './types';
const CharacterSelectionFlow = lazy(async () => {
const module = await import('./CharacterSelectionFlow');
const RpgEntryCharacterSelectView = lazy(async () => {
const module = await import('../rpg-entry');
return {
default: module.CharacterSelectionFlow,
default: module.RpgEntryCharacterSelectView,
};
});
const PreGameSelectionFlow = lazy(async () => {
const module = await import('./PreGameSelectionFlow');
const RpgEntryFlowShell = lazy(async () => {
const module = await import('../rpg-entry');
return {
default: module.PreGameSelectionFlow,
default: module.RpgEntryFlowShell,
};
});
const GameShellStoryPanels = lazy(async () => {
const module = await import('./GameShellStoryPanels');
const RpgRuntimePanelRouter = lazy(async () => {
const module = await import('../rpg-runtime-panels');
return {
default: module.GameShellStoryPanels,
default: module.RpgRuntimePanelRouter,
};
});
@@ -52,7 +54,54 @@ function MainContentLoadingFallback({ label }: { label: string }) {
);
}
export function GameShellMainContent({
export interface RpgRuntimeStageRouterProps {
gameState: GameState;
visibleGameState: GameState;
visibleCurrentStory: StoryMoment | null;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
isCharacterSelectionStage: boolean;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
displayedOptions: StoryOption[];
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: RpgAdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
resetForSaveAndExit: () => void;
handleSaveAndExit: () => void;
}
/**
* RPG
*
*/
export function RpgRuntimeStageRouter({
gameState,
visibleGameState,
visibleCurrentStory,
@@ -93,48 +142,7 @@ export function GameShellMainContent({
onMusicVolumeChange,
resetForSaveAndExit,
handleSaveAndExit,
}: {
gameState: GameState;
visibleGameState: GameState;
visibleCurrentStory: StoryMoment | null;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
isCharacterSelectionStage: boolean;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
displayedOptions: StoryOption[];
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: GameShellAdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
resetForSaveAndExit: () => void;
handleSaveAndExit: () => void;
}) {
}: RpgRuntimeStageRouterProps) {
const isPlatformShell = !gameState.worldType;
return (
@@ -161,7 +169,7 @@ export function GameShellMainContent({
<Suspense
fallback={<MainContentLoadingFallback label="正在加载平台首页..." />}
>
<PreGameSelectionFlow
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={gameState}
@@ -185,7 +193,7 @@ export function GameShellMainContent({
<Suspense
fallback={<MainContentLoadingFallback label="正在加载角色选择..." />}
>
<CharacterSelectionFlow
<RpgEntryCharacterSelectView
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
onBack={() => {
@@ -199,11 +207,16 @@ export function GameShellMainContent({
)}
{visibleGameState.playerCharacter && visibleCurrentStory && (
<motion.div key="story-flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full min-h-0 flex-col">
<motion.div
key="story-flow"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<MainContentLoadingFallback label="正在加载冒险面板..." />}
>
<GameShellStoryPanels
<RpgRuntimePanelRouter
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
isLoading={isLoading}
@@ -243,3 +256,5 @@ export function GameShellMainContent({
</div>
);
}
export default RpgRuntimeStageRouter;

View File

@@ -0,0 +1,36 @@
export {
RpgRuntimeCanvasStage,
type RpgRuntimeCanvasStageProps,
} from './RpgRuntimeCanvasStage';
export {
RpgRuntimeOverlayHost,
type RpgRuntimeOverlayHostProps,
} from './RpgRuntimeOverlayHost';
export {
RpgRuntimeShell,
type RpgRuntimeShellProps,
} from './RpgRuntimeShell';
export {
RpgRuntimeStageRouter,
type RpgRuntimeStageRouterProps,
} from './RpgRuntimeStageRouter';
export {
type RpgAdventureStatistics,
type RpgRuntimeDialogueIndicator,
} from './types';
export {
ModalLoadingFallback,
PanelLoadingFallback,
} from './rpgRuntimeLoaders';
export {
useRpgRuntimeShellViewModel,
type RpgRuntimeShellViewModelResult,
type UseRpgRuntimeShellViewModelParams,
} from './useRpgRuntimeShellViewModel';
export { useRpgRuntimeOverlayState } from './useRpgRuntimeOverlayState';
export {
SCENE_TRANSITION_FUNCTION_MODES,
useRpgSceneTransitionModel,
type SceneTransitionPhase,
type SceneTransitionTriggerMode,
} from './useRpgSceneTransitionModel';

View File

@@ -1,5 +1,9 @@
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
/**
* RPG
* `rpg-runtime-shell`
*/
export function ModalLoadingFallback({
label,
onClose,
@@ -15,7 +19,7 @@ export function ModalLoadingFallback({
<div
className="pixel-nine-slice pixel-modal-shell flex min-h-40 w-full max-w-md items-center justify-center px-6 py-8 text-center text-sm text-zinc-300 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
{label}
</div>
@@ -23,13 +27,20 @@ export function ModalLoadingFallback({
);
}
/**
* RPG
* UI
*/
export function PanelLoadingFallback({
label,
}: {
label: string;
}) {
return (
<div className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500" style={getNineSliceStyle(UI_CHROME.modalPanel)}>
<div
className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
>
{label}
</div>
);

View File

@@ -1,4 +1,4 @@
import type { BottomTab } from '../../hooks/useGameFlow';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -7,7 +7,7 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
} from '../../hooks/rpg-runtime-story';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
Character,
@@ -18,7 +18,7 @@ import type {
StoryOption,
} from '../../types';
export interface GameShellSessionProps {
export interface RpgRuntimeSessionProps {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
@@ -29,7 +29,7 @@ export interface GameShellSessionProps {
setIsMapOpen: (open: boolean) => void;
}
export interface GameShellStoryProps {
export interface RpgRuntimeStoryProps {
displayedOptions: StoryOption[];
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
@@ -46,7 +46,7 @@ export interface GameShellStoryProps {
goalUi: GoalFlowUi;
}
export interface GameShellEntryProps {
export interface RpgEntrySessionProps {
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
@@ -57,25 +57,25 @@ export interface GameShellEntryProps {
handleCharacterSelect: (character: Character) => void;
}
export interface GameShellCompanionProps {
export interface RpgRuntimeCompanionProps {
companionRenderStates: CompanionRenderState[];
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
export interface GameShellAudioProps {
export interface RpgRuntimeAudioProps {
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
}
export interface GameShellDialogueIndicator {
export interface RpgRuntimeDialogueIndicator {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
}
export interface GameShellAdventureStatistics {
export interface RpgAdventureStatistics {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
@@ -95,10 +95,10 @@ export interface GameShellAdventureStatistics {
rosterCompanionCount: number;
}
export interface GameShellProps {
session: GameShellSessionProps;
story: GameShellStoryProps;
entry: GameShellEntryProps;
companions: GameShellCompanionProps;
audio: GameShellAudioProps;
export interface RpgRuntimeShellProps {
session: RpgRuntimeSessionProps;
story: RpgRuntimeStoryProps;
entry: RpgEntrySessionProps;
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import type { GameState } from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from './PreGameSelectionFlow';
import type { SelectionStage } from '../rpg-entry';
type OverlayPanel = 'character' | 'inventory' | null;
@@ -18,7 +18,11 @@ function useLazyModalMount(active: boolean) {
return shouldMount;
}
export function useGameShellViewModel(params: {
/**
* RPG overlay
* UI GameShell
*/
export function useRpgRuntimeOverlayState(params: {
gameState: GameState;
isMapOpen: boolean;
characterChatModalOpen: boolean;
@@ -32,17 +36,22 @@ export function useGameShellViewModel(params: {
} = params;
const [selectionStage, setSelectionStage] = useState<SelectionStage>('platform');
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
const [selectedSceneEntity, setSelectedSceneEntity] = useState<GameCanvasEntitySelection | null>(null);
const [selectedSceneEntity, setSelectedSceneEntity] =
useState<GameCanvasEntitySelection | null>(null);
const [showTeamModal, setShowTeamModal] = useState(false);
useEffect(() => {
setSelectedSceneEntity(null);
}, [gameState.currentScenePreset?.id, gameState.playerCharacter?.id]);
const shouldMountAdventureEntityModal = useLazyModalMount(Boolean(selectedSceneEntity));
const shouldMountAdventureEntityModal = useLazyModalMount(
Boolean(selectedSceneEntity),
);
const shouldMountCampModal = useLazyModalMount(showTeamModal);
const shouldMountMapModal = useLazyModalMount(isMapOpen);
const shouldMountCharacterChatModal = useLazyModalMount(characterChatModalOpen);
const shouldMountCharacterChatModal = useLazyModalMount(
characterChatModalOpen,
);
const shouldMountNpcModals = useLazyModalMount(hasNpcModalOpen);
const openOverlayPanel = (panel: Exclude<OverlayPanel, null>) => {
@@ -51,7 +60,8 @@ export function useGameShellViewModel(params: {
};
const closeOverlayPanel = () => setOverlayPanel(null);
const openPartyMemberDetails = (selection: GameCanvasEntitySelection) => setSelectedSceneEntity(selection);
const openPartyMemberDetails = (selection: GameCanvasEntitySelection) =>
setSelectedSceneEntity(selection);
const closeAdventureEntityModal = () => setSelectedSceneEntity(null);
const openCampModal = () => setShowTeamModal(true);
const closeCampModal = () => setShowTeamModal(false);

View File

@@ -6,8 +6,8 @@ import {
buildAdventureStatistics,
buildCanvasCompanionRenderStates,
buildCharacterChatSummaries,
buildGameShellDialogueIndicator,
} from './useGameShellRuntimeViewModel';
buildRpgRuntimeDialogueIndicator,
} from '../rpg-runtime-shell/useRpgRuntimeShellViewModel';
function createBaseGameState(): GameState {
return {
@@ -169,7 +169,7 @@ function createBaseGameState(): GameState {
};
}
describe('useGameShellRuntimeViewModel helpers', () => {
describe('useRpgRuntimeShellViewModel helpers', () => {
it('builds a dialogue indicator only for active npc dialogue playback', () => {
const state = {
...createBaseGameState(),
@@ -199,7 +199,7 @@ describe('useGameShellRuntimeViewModel helpers', () => {
} satisfies StoryMoment;
expect(
buildGameShellDialogueIndicator({
buildRpgRuntimeDialogueIndicator({
isLoading: true,
visibleGameState: state,
visibleCurrentStory: story,
@@ -211,7 +211,7 @@ describe('useGameShellRuntimeViewModel helpers', () => {
});
expect(
buildGameShellDialogueIndicator({
buildRpgRuntimeDialogueIndicator({
isLoading: false,
visibleGameState: state,
visibleCurrentStory: story,
@@ -280,6 +280,10 @@ describe('useGameShellRuntimeViewModel helpers', () => {
scenesTraveled: 5,
currentSceneName: '断桥旧哨',
playerCurrency: 18,
playerLevel: 1,
playerCurrentLevelXp: 0,
playerXpToNextLevel: 60,
playerTotalXp: 0,
inventoryItemCount: 5,
inventoryStackCount: 2,
activeCompanionCount: 2,

View File

@@ -11,21 +11,21 @@ import type {
StoryOption,
} from '../../types';
import type {
GameShellAdventureStatistics,
GameShellDialogueIndicator,
GameShellProps,
RpgAdventureStatistics,
RpgRuntimeDialogueIndicator,
RpgRuntimeShellProps,
} from './types';
import { useGameShellViewModel } from './useGameShellViewModel';
import { useRpgRuntimeOverlayState } from './useRpgRuntimeOverlayState';
import {
SCENE_TRANSITION_FUNCTION_MODES,
useSceneTransitionModel,
} from './useSceneTransitionModel';
useRpgSceneTransitionModel,
} from './useRpgSceneTransitionModel';
export function buildGameShellDialogueIndicator(params: {
export function buildRpgRuntimeDialogueIndicator(params: {
isLoading: boolean;
visibleGameState: GameState;
visibleCurrentStory: StoryMoment | null;
}): GameShellDialogueIndicator | null {
}): RpgRuntimeDialogueIndicator | null {
const { isLoading, visibleGameState, visibleCurrentStory } = params;
if (
!isLoading ||
@@ -79,7 +79,7 @@ export function buildAdventureStatistics(params: {
gameState: GameState;
visibleGameState: GameState;
livePlayTimeMs: number;
}): GameShellAdventureStatistics {
}): RpgAdventureStatistics {
const { gameState, visibleGameState, livePlayTimeMs } = params;
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
@@ -113,8 +113,17 @@ export function buildAdventureStatistics(params: {
};
}
export function useGameShellRuntimeViewModel(
params: Pick<GameShellProps, 'session' | 'story' | 'companions'>,
export type UseRpgRuntimeShellViewModelParams = Pick<
RpgRuntimeShellProps,
'session' | 'story' | 'companions'
>;
/**
* RPG
* overlay companion
*/
export function useRpgRuntimeShellViewModel(
params: UseRpgRuntimeShellViewModelParams,
) {
const { session, story, companions } = params;
const { gameState, currentStory, isLoading, isMapOpen } = session;
@@ -132,13 +141,13 @@ export function useGameShellRuntimeViewModel(
const hasNpcModalOpen = Boolean(
npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal,
);
const shellViewModel = useGameShellViewModel({
const shellViewModel = useRpgRuntimeOverlayState({
gameState,
isMapOpen,
characterChatModalOpen: Boolean(characterChatUi.modal),
hasNpcModalOpen,
});
const sceneTransitionModel = useSceneTransitionModel({
const sceneTransitionModel = useRpgSceneTransitionModel({
gameState,
currentStory,
openingCampSceneId,
@@ -158,7 +167,7 @@ export function useGameShellRuntimeViewModel(
const dialogueIndicator = useMemo(
() =>
buildGameShellDialogueIndicator({
buildRpgRuntimeDialogueIndicator({
isLoading,
visibleGameState,
visibleCurrentStory,
@@ -234,3 +243,7 @@ export function useGameShellRuntimeViewModel(
handleSceneTransitionChoice,
};
}
export type RpgRuntimeShellViewModelResult = ReturnType<
typeof useRpgRuntimeShellViewModel
>;

View File

@@ -0,0 +1,224 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameState, StoryMoment } from '../../types';
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
type SceneTransitionRequest = {
mode: SceneTransitionTriggerMode;
baselineSceneId: string | null;
baselineContentKey: string;
exitComplete: boolean;
};
const DEFAULT_SCENE_SWITCH_EXIT_MS = 5000;
const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
Record<string, SceneTransitionTriggerMode>
> = {
idle_travel_next_scene: 'scene-change',
camp_travel_home_scene: 'scene-change',
idle_explore_forward: 'content-change',
idle_follow_clue: 'content-change',
};
function buildSceneTransitionContentKey(
gameState: GameState,
currentStory: StoryMoment | null,
) {
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
const monsterKey = gameState.sceneHostileNpcs
.map(
(monster) =>
`${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`,
)
.join('|');
const storyKey = currentStory
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
: 'story:none';
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
}
/**
* RPG 运行态场景过场模型真实入口。
* 第三批收口后,`rpg-runtime-shell` 直接承载场景过场状态。
*/
export function useRpgSceneTransitionModel(params: {
gameState: GameState;
currentStory: StoryMoment | null;
openingCampSceneId: string | null;
}) {
const { gameState, currentStory, openingCampSceneId } = params;
const [renderGameState, setRenderGameState] = useState(gameState);
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
const [sceneTransitionPhase, setSceneTransitionPhase] =
useState<SceneTransitionPhase>('idle');
const [sceneTransitionToken, setSceneTransitionToken] = useState(0);
const [sceneTransitionDurations, setSceneTransitionDurations] = useState({
exitMs: DEFAULT_SCENE_SWITCH_EXIT_MS,
entryMs: DEFAULT_SCENE_SWITCH_ENTRY_MS,
});
const pendingScenePayloadRef = useRef<{
gameState: GameState;
currentStory: StoryMoment | null;
}>({
gameState,
currentStory,
});
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
useEffect(() => {
return () => {
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
window.clearTimeout(timerId),
);
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
};
}, []);
const startSceneEntering = useCallback(
(payload: { gameState: GameState; currentStory: StoryMoment | null }) => {
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
window.clearTimeout(timerId),
);
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
setRenderGameState(payload.gameState);
setRenderCurrentStory(payload.currentStory);
setSceneTransitionToken((current) => current + 1);
setSceneTransitionPhase('entering');
const entryTimerId = window.setTimeout(() => {
setSceneTransitionPhase('idle');
}, sceneTransitionDurations.entryMs);
sceneTransitionTimerIdsRef.current.push(entryTimerId);
},
[sceneTransitionDurations.entryMs],
);
const beginSceneTransition = useCallback(
(mode: SceneTransitionTriggerMode) => {
if (sceneTransitionPhase !== 'idle') return;
pendingScenePayloadRef.current = { gameState, currentStory };
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
window.clearTimeout(timerId),
);
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = {
mode,
baselineSceneId:
renderGameState.currentScenePreset?.id ??
gameState.currentScenePreset?.id ??
null,
baselineContentKey: buildSceneTransitionContentKey(
renderGameState,
renderCurrentStory,
),
exitComplete: false,
};
setSceneTransitionPhase('exiting');
const exitTimerId = window.setTimeout(() => {
const request = sceneTransitionRequestRef.current;
if (!request) return;
request.exitComplete = true;
const pendingPayload = pendingScenePayloadRef.current;
const isReady =
request.mode === 'scene-change'
? (pendingPayload.gameState.currentScenePreset?.id ?? null) !==
request.baselineSceneId
: buildSceneTransitionContentKey(
pendingPayload.gameState,
pendingPayload.currentStory,
) !== request.baselineContentKey;
if (isReady) {
startSceneEntering(pendingPayload);
}
}, sceneTransitionDurations.exitMs);
sceneTransitionTimerIdsRef.current.push(exitTimerId);
},
[
currentStory,
gameState,
renderCurrentStory,
renderGameState,
sceneTransitionDurations.exitMs,
sceneTransitionPhase,
startSceneEntering,
],
);
useEffect(() => {
pendingScenePayloadRef.current = { gameState, currentStory };
const request = sceneTransitionRequestRef.current;
if (sceneTransitionPhase === 'exiting' && request?.exitComplete) {
const isReady =
request.mode === 'scene-change'
? (gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId
: buildSceneTransitionContentKey(gameState, currentStory) !==
request.baselineContentKey;
if (isReady) {
startSceneEntering({ gameState, currentStory });
}
return;
}
if (sceneTransitionPhase !== 'exiting') {
setRenderGameState(gameState);
setRenderCurrentStory(currentStory);
}
}, [currentStory, gameState, sceneTransitionPhase, startSceneEntering]);
useEffect(() => {
if (sceneTransitionPhase !== 'idle') {
return;
}
if (renderGameState.playerCharacter) {
return;
}
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
if (gameState.storyHistory.length > 0) {
return;
}
if (
!openingCampSceneId ||
gameState.currentScenePreset?.id !== openingCampSceneId
) {
return;
}
startSceneEntering({ gameState, currentStory });
}, [
currentStory,
gameState,
openingCampSceneId,
renderGameState.playerCharacter,
sceneTransitionPhase,
startSceneEntering,
]);
return {
visibleGameState:
sceneTransitionPhase === 'idle' ? gameState : renderGameState,
visibleCurrentStory:
sceneTransitionPhase === 'idle' ? currentStory : renderCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
beginSceneTransition,
};
}