1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '保存' }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
RpgCreationShell,
|
||||
type RpgCreationShellProps,
|
||||
} from './RpgCreationShell';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
4
src/components/rpg-creation-asset-studio/index.ts
Normal file
4
src/components/rpg-creation-asset-studio/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
RpgCreationRoleAssetStudioModal,
|
||||
type RpgCreationRoleAssetStudioModalProps,
|
||||
} from './RpgCreationRoleAssetStudioModal';
|
||||
@@ -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: '默认倒地动画',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
} from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
|
||||
/**
|
||||
* 工作包 C 第一轮先把发布相关 API 出口收口到独立 client。
|
||||
* 后续场景资产工坊复用时,可以直接沿用这里的发布边界。
|
||||
*/
|
||||
export const roleAssetStudioPublishClient = {
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CampSceneEditor as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -0,0 +1 @@
|
||||
export { WorldCoverEditor as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -0,0 +1 @@
|
||||
export { LandmarkEditor as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PlayableNpcEditor as default,
|
||||
StoryNpcEditor,
|
||||
} from './RpgCreationEntityEditorShared';
|
||||
@@ -0,0 +1 @@
|
||||
export { SectionPanel as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -0,0 +1 @@
|
||||
export { WorldEditor as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
229
src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx
Normal file
229
src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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()}
|
||||
@@ -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: {
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
20
src/components/rpg-entry/RpgEntryFlowShell.tsx
Normal file
20
src/components/rpg-entry/RpgEntryFlowShell.tsx
Normal 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;
|
||||
718
src/components/rpg-entry/RpgEntryFlowShellImpl.tsx
Normal file
718
src/components/rpg-entry/RpgEntryFlowShellImpl.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
27
src/components/rpg-entry/index.ts
Normal file
27
src/components/rpg-entry/index.ts
Normal 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';
|
||||
110
src/components/rpg-entry/rpgEntryShared.ts
Normal file
110
src/components/rpg-entry/rpgEntryShared.ts
Normal 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;
|
||||
39
src/components/rpg-entry/rpgEntryTypes.ts
Normal file
39
src/components/rpg-entry/rpgEntryTypes.ts
Normal 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;
|
||||
@@ -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 '江湖';
|
||||
109
src/components/rpg-entry/useRpgCreationAgentOperationPolling.ts
Normal file
109
src/components/rpg-entry/useRpgCreationAgentOperationPolling.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
92
src/components/rpg-entry/useRpgCreationEnterWorld.ts
Normal file
92
src/components/rpg-entry/useRpgCreationEnterWorld.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
392
src/components/rpg-entry/useRpgCreationResultAutosave.ts
Normal file
392
src/components/rpg-entry/useRpgCreationResultAutosave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
570
src/components/rpg-entry/useRpgCreationSessionController.ts
Normal file
570
src/components/rpg-entry/useRpgCreationSessionController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
346
src/components/rpg-entry/useRpgEntryBootstrap.ts
Normal file
346
src/components/rpg-entry/useRpgEntryBootstrap.ts
Normal 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;
|
||||
46
src/components/rpg-entry/useRpgEntryCharacterSelect.ts
Normal file
46
src/components/rpg-entry/useRpgEntryCharacterSelect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
444
src/components/rpg-entry/useRpgEntryLibraryDetail.ts
Normal file
444
src/components/rpg-entry/useRpgEntryLibraryDetail.ts
Normal 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;
|
||||
55
src/components/rpg-entry/useRpgEntryNavigation.ts
Normal file
55
src/components/rpg-entry/useRpgEntryNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
28
src/components/rpg-entry/useRpgEntrySaveResume.ts
Normal file
28
src/components/rpg-entry/useRpgEntrySaveResume.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
@@ -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,
|
||||
}}
|
||||
@@ -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;
|
||||
@@ -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 =
|
||||
@@ -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;
|
||||
8
src/components/rpg-runtime-panels/index.ts
Normal file
8
src/components/rpg-runtime-panels/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
RpgAdventurePanel,
|
||||
type RpgAdventurePanelProps,
|
||||
} from './RpgAdventurePanel';
|
||||
export {
|
||||
RpgRuntimePanelRouter,
|
||||
type RpgRuntimePanelRouterProps,
|
||||
} from './RpgRuntimePanelRouter';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
36
src/components/rpg-runtime-shell/index.ts
Normal file
36
src/components/rpg-runtime-shell/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
@@ -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
|
||||
>;
|
||||
224
src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts
Normal file
224
src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user