Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { GameShellRuntime } from './components/game-shell/GameShellRuntime.tsx';
|
||||
import { useGameShellRuntime } from './hooks/useGameShellRuntime';
|
||||
import { RpgRuntimeShell } from './components/rpg-runtime-shell';
|
||||
import { useRpgRuntimeSession } from './hooks/rpg-session';
|
||||
|
||||
export default function App() {
|
||||
const gameShellProps = useGameShellRuntime();
|
||||
const gameShellProps = useRpgRuntimeSession();
|
||||
|
||||
return <GameShellRuntime {...gameShellProps} />;
|
||||
return <RpgRuntimeShell {...gameShellProps} />;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
@@ -50,7 +50,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,26 +46,11 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldEntityEditorModal', () => ({
|
||||
CustomWorldEntityEditorModal: () => null,
|
||||
vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({
|
||||
RpgCreationEntityEditorModal: () => null,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../services/assetReadUrlService', () => ({
|
||||
resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return value ? `https://signed.example${value}` : '';
|
||||
}),
|
||||
isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value);
|
||||
}),
|
||||
}));
|
||||
|
||||
async function loadAiService() {
|
||||
return import('../services/aiService');
|
||||
}
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
@@ -270,7 +277,7 @@ function ResultViewHarness() {
|
||||
const [profile, setProfile] = useState(baseProfile);
|
||||
|
||||
return (
|
||||
<CustomWorldResultView
|
||||
<RpgCreationResultView
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
@@ -284,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;
|
||||
@@ -326,13 +332,9 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
|
||||
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', async () => {
|
||||
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText('玩家幻想')).toBeTruthy();
|
||||
expect(screen.getByText('主题边界')).toBeTruthy();
|
||||
@@ -361,7 +363,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
|
||||
} as CustomWorldProfile;
|
||||
|
||||
render(
|
||||
<CustomWorldResultView
|
||||
<RpgCreationResultView
|
||||
profile={profile}
|
||||
previewCharacters={[
|
||||
{
|
||||
@@ -393,7 +395,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
|
||||
|
||||
const portrait = screen.getByRole('img', { name: '云止' });
|
||||
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'https://signed.example/generated-characters/playable-portrait/master.png',
|
||||
'/generated-characters/playable-portrait/master.png',
|
||||
);
|
||||
expect(screen.getByText('已生成主图')).toBeTruthy();
|
||||
});
|
||||
@@ -410,59 +412,110 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
|
||||
|
||||
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
|
||||
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
|
||||
'/generated-custom-world-scenes/scene-act-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('asset debug panel opens signed image link in dev mode', async () => {
|
||||
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalImportMetaEnv = import.meta.env;
|
||||
const originalUrl = window.location.href;
|
||||
const originalImage = globalThis.Image;
|
||||
|
||||
Object.defineProperty(import.meta, 'env', {
|
||||
value: {
|
||||
...originalImportMetaEnv,
|
||||
DEV: true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
window.history.pushState({}, '', '/?debugCustomWorldAssets=1');
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
readOnly
|
||||
compactAgentResultMode
|
||||
/>,
|
||||
);
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => {
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
value: MockImage,
|
||||
configurable: true,
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /^编辑$/u })).toBeNull();
|
||||
|
||||
try {
|
||||
render(<ResultViewHarness />);
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景\s*2/u }));
|
||||
|
||||
const signedLink = await screen.findByRole('link', {
|
||||
name: /打开 沉钟栈桥章节 \/ 潮声逼近幕图签名图/u,
|
||||
});
|
||||
expect((signedLink as HTMLAnchorElement).href).toBe(
|
||||
'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(import.meta, 'env', {
|
||||
value: originalImportMetaEnv,
|
||||
configurable: true,
|
||||
});
|
||||
window.history.pushState({}, '', originalUrl);
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
value: originalImage,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
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,849 +0,0 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
generateCustomWorldLandmark,
|
||||
generateCustomWorldPlayableNpc,
|
||||
generateCustomWorldStoryNpc,
|
||||
} from '../services/aiService';
|
||||
import { resolveAssetReadUrl } from '../services/assetReadUrlService';
|
||||
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';
|
||||
}
|
||||
|
||||
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',
|
||||
}: 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 [assetDebugResolvedImageMap, setAssetDebugResolvedImageMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const assetDebugResolvedEntries = useMemo(
|
||||
() =>
|
||||
assetDebugEntries.map((entry) => ({
|
||||
...entry,
|
||||
hasResolvedImageSrc: Object.prototype.hasOwnProperty.call(
|
||||
assetDebugResolvedImageMap,
|
||||
entry.id,
|
||||
),
|
||||
resolvedImageSrc: assetDebugResolvedImageMap[entry.id] || entry.imageSrc,
|
||||
})),
|
||||
[assetDebugEntries, assetDebugResolvedImageMap],
|
||||
);
|
||||
const assetDebugDetectableEntries = useMemo(
|
||||
() =>
|
||||
assetDebugResolvedEntries.filter(
|
||||
(entry) =>
|
||||
entry.hasResolvedImageSrc && Boolean(entry.resolvedImageSrc.trim()),
|
||||
),
|
||||
[assetDebugResolvedEntries],
|
||||
);
|
||||
|
||||
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) {
|
||||
setAssetDebugResolvedImageMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetDebugEntries.length === 0) {
|
||||
setAssetDebugResolvedImageMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void Promise.all(
|
||||
assetDebugEntries.map(async (entry) => [
|
||||
entry.id,
|
||||
await resolveAssetReadUrl(entry.imageSrc),
|
||||
] as const),
|
||||
).then((resolvedEntries) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetDebugResolvedImageMap(Object.fromEntries(resolvedEntries));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [assetDebugEnabled, assetDebugEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDebugEnabled) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetDebugDetectableEntries.length === 0) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const cleanupList: Array<() => void> = [];
|
||||
|
||||
// 诊断面板只根据已解析地址做探测,避免状态流里反复访问原始 generated-* 路径。
|
||||
setAssetDebugStatusMap(
|
||||
Object.fromEntries(
|
||||
assetDebugDetectableEntries.map((entry) => [entry.id, 'loading' as const]),
|
||||
),
|
||||
);
|
||||
|
||||
assetDebugDetectableEntries.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.resolvedImageSrc;
|
||||
cleanupList.push(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupList.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, [assetDebugDetectableEntries, assetDebugEnabled]);
|
||||
|
||||
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 ? undefined : createLabel}
|
||||
onCreateAction={
|
||||
readOnly || !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]">
|
||||
{assetDebugResolvedEntries.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">
|
||||
{assetDebugResolvedEntries.length > 0 ? (
|
||||
assetDebugResolvedEntries.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">
|
||||
{entry.hasResolvedImageSrc ? (
|
||||
<a
|
||||
href={entry.resolvedImageSrc}
|
||||
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 className="text-xs text-zinc-500">
|
||||
正在解析签名地址...
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface DeveloperTeamModalProps {
|
||||
isOpen: boolean;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeveloperTeamModal({
|
||||
isOpen,
|
||||
message,
|
||||
onClose,
|
||||
}: DeveloperTeamModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[74] flex items-center justify-center bg-black/78 p-3 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-[min(96vw,42rem)] flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mt-1 text-sm font-semibold text-white">{'\u5f00\u53d1\u56e2\u961f'}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
aria-label="关闭开发团队弹窗"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel flex flex-col items-center gap-5"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 16 })}
|
||||
>
|
||||
<div className="whitespace-pre-line text-center text-sm leading-7 text-zinc-100">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
import {AnimatePresence, motion} from 'motion/react';
|
||||
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import {getWorldCampScenePreset} from '../data/scenePresets';
|
||||
import {BottomTab} from '../hooks/useGameFlow';
|
||||
import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
type BattleRewardUi,
|
||||
type CharacterChatUi,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type StoryGenerationNpcUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type CompanionRenderState,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../types';
|
||||
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
|
||||
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
|
||||
import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow';
|
||||
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel';
|
||||
import {useGameShellViewModel} from './game-shell/useGameShellViewModel';
|
||||
import {GameCanvas} from './GameCanvas';
|
||||
import {PixelIcon} from './PixelIcon';
|
||||
|
||||
interface GameShellSessionProps {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
aiError: string | null;
|
||||
bottomTab: BottomTab;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
isMapOpen: boolean;
|
||||
setIsMapOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface GameShellStoryProps {
|
||||
displayedOptions: StoryOption[];
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
|
||||
interface GameShellCompanionProps {
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
|
||||
onBenchCompanion: (npcId: string) => void;
|
||||
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
|
||||
}
|
||||
|
||||
interface GameShellAudioProps {
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
interface GameShellProps {
|
||||
session: GameShellSessionProps;
|
||||
story: GameShellStoryProps;
|
||||
entry: GameShellEntryProps;
|
||||
companions: GameShellCompanionProps;
|
||||
audio: GameShellAudioProps;
|
||||
}
|
||||
|
||||
const AdventureEntityModal = lazy(async () => {
|
||||
const module = await import('./AdventureEntityModal');
|
||||
|
||||
return {
|
||||
default: module.AdventureEntityModal,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterChatModal = lazy(async () => {
|
||||
const module = await import('./CharacterChatModal');
|
||||
|
||||
return {
|
||||
default: module.CharacterChatModal,
|
||||
};
|
||||
});
|
||||
|
||||
const CompanionCampModal = lazy(async () => {
|
||||
const module = await import('./CompanionCampModal');
|
||||
|
||||
return {
|
||||
default: module.CompanionCampModal,
|
||||
};
|
||||
});
|
||||
|
||||
const MapModal = lazy(async () => {
|
||||
const module = await import('./MapModal');
|
||||
|
||||
return {
|
||||
default: module.MapModal,
|
||||
};
|
||||
});
|
||||
|
||||
const NpcModals = lazy(async () => {
|
||||
const module = await import('./NpcModals');
|
||||
|
||||
return {
|
||||
default: module.NpcModals,
|
||||
};
|
||||
});
|
||||
|
||||
const AdventurePanel = lazy(async () => {
|
||||
const module = await import('./AdventurePanel');
|
||||
|
||||
return {
|
||||
default: module.AdventurePanel,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterPanel = lazy(async () => {
|
||||
const module = await import('./CharacterPanel');
|
||||
|
||||
return {
|
||||
default: module.CharacterPanel,
|
||||
};
|
||||
});
|
||||
|
||||
const InventoryPanel = lazy(async () => {
|
||||
const module = await import('./InventoryPanel');
|
||||
|
||||
return {
|
||||
default: module.InventoryPanel,
|
||||
};
|
||||
});
|
||||
|
||||
function ModalLoadingFallback({
|
||||
label,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
onClose?: (() => void) | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={onClose ?? undefined}
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameShell({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
} = session;
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
overlayPanel,
|
||||
openOverlayPanel,
|
||||
closeOverlayPanel,
|
||||
selectedSceneEntity,
|
||||
setSelectedSceneEntity,
|
||||
openPartyMemberDetails,
|
||||
closeAdventureEntityModal,
|
||||
showTeamModal,
|
||||
openCampModal,
|
||||
closeCampModal,
|
||||
resetForSaveAndExit,
|
||||
shouldMountAdventureEntityModal,
|
||||
shouldMountCampModal,
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
} = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
} = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
});
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const collapseTopStage = gameState.currentScene === 'Selection';
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const visibleStoryForRender = visibleCurrentStory;
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
} as const;
|
||||
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
|
||||
),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(() => {
|
||||
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
|
||||
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
|
||||
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
const activeSceneAct = useMemo(
|
||||
() => resolveActiveSceneActBlueprint({
|
||||
profile: visibleGameState.customWorldProfile,
|
||||
sceneId: visibleGameState.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: visibleGameState.storyEngineMemory,
|
||||
}),
|
||||
[
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
visibleGameState.storyEngineMemory,
|
||||
],
|
||||
);
|
||||
const activeSceneChapter = useMemo(() => {
|
||||
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
|
||||
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|
||||
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
|
||||
) ?? null
|
||||
);
|
||||
}, [
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
]);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
}),
|
||||
[
|
||||
gameState.runtimeStats.itemsUsed,
|
||||
gameState.runtimeStats.hostileNpcsDefeated,
|
||||
gameState.runtimeStats.questsAccepted,
|
||||
gameState.runtimeStats.scenesTraveled,
|
||||
livePlayTimeMs,
|
||||
visibleGameState.companions.length,
|
||||
visibleGameState.currentScenePreset?.name,
|
||||
visibleGameState.playerCurrency,
|
||||
visibleGameState.playerInventory,
|
||||
visibleGameState.quests,
|
||||
visibleGameState.roster.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
}, [beginSceneTransition, handleChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
<div className={`relative ${collapseTopStage ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{collapseTopStage ? null : (
|
||||
<GameCanvas
|
||||
scrollWorld={visibleGameState.scrollWorld}
|
||||
animationState={visibleGameState.animationState}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
playerActionMode={visibleGameState.playerActionMode}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
activeCombatEffects={visibleGameState.activeCombatEffects}
|
||||
companions={canvasCompanionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
|
||||
onEntitySelect={setSelectedSceneEntity}
|
||||
onSceneNameClick={() => setIsMapOpen(true)}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
onSceneTransitionDurationsChange={setSceneTransitionDurations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
|
||||
backgroundImage: isCharacterSelectionStage
|
||||
? undefined
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!gameState.worldType && (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gameState.worldType && !gameState.playerCharacter && (
|
||||
<motion.div
|
||||
key="character-select-shell"
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -12}}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<CharacterSelectionFlow
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{visibleGameState.playerCharacter && visibleStoryForRender && (
|
||||
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
|
||||
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={() => setBottomTab('character')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">队伍</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('adventure')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">冒险</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('inventory')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bottomTab === 'character' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={visibleGameState.worldType}
|
||||
customWorldProfile={visibleGameState.customWorldProfile}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerEquipment={visibleGameState.playerEquipment}
|
||||
activeBuildBuffs={visibleGameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
companionResolutions={
|
||||
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
||||
}
|
||||
onOpenCamp={openCampModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'adventure' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
|
||||
<AdventurePanel
|
||||
aiError={aiError}
|
||||
currentStory={visibleStoryForRender}
|
||||
isLoading={isLoading}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={shouldHideStoryOptions}
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
onRefreshOptions={handleRefreshOptions}
|
||||
onChoice={handleSceneTransitionChoice}
|
||||
onSubmitNpcChatInput={handleNpcChatInput}
|
||||
onExitNpcChat={exitNpcChat}
|
||||
onOpenCharacter={() => openOverlayPanel('character')}
|
||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
battleRewardUi={battleRewardUi}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
|
||||
chapterState={visibleGameState.chapterState ?? null}
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
currentSceneActTitle={activeSceneAct?.title ?? null}
|
||||
currentSceneActIndex={
|
||||
activeSceneChapter && activeSceneAct
|
||||
? (() => {
|
||||
const actIndex = activeSceneChapter.acts.findIndex(
|
||||
act => act.id === activeSceneAct.id,
|
||||
);
|
||||
return actIndex >= 0 ? actIndex + 1 : null;
|
||||
})()
|
||||
: null
|
||||
}
|
||||
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={() => {
|
||||
resetForSaveAndExit();
|
||||
handleSaveAndExit();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'inventory' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
playerInventory={visibleGameState.playerInventory}
|
||||
playerCurrency={visibleGameState.playerCurrency}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{shouldMountAdventureEntityModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
|
||||
<AdventureEntityModal
|
||||
selection={selectedSceneEntity}
|
||||
gameState={gameState}
|
||||
onClose={closeAdventureEntityModal}
|
||||
onOpenCharacterChat={target => {
|
||||
closeAdventureEntityModal();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{overlayPanel && gameState.playerCharacter && (
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={closeOverlayPanel}
|
||||
>
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.96, y: 8}}
|
||||
animate={{opacity: 1, scale: 1, y: 0}}
|
||||
exit={{opacity: 0, scale: 0.96, y: 8}}
|
||||
transition={{duration: 0.18, ease: 'easeOut'}}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOverlayPanel}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 p-5">
|
||||
{overlayPanel === 'character' ? (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
playerMaxMana={gameState.playerMaxMana}
|
||||
playerEquipment={gameState.playerEquipment}
|
||||
activeBuildBuffs={gameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={gameState.npcStates}
|
||||
quests={gameState.quests}
|
||||
onOpenCamp={() => {
|
||||
closeOverlayPanel();
|
||||
openCampModal();
|
||||
}}
|
||||
onOpenCharacterChat={target => {
|
||||
closeOverlayPanel();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
worldType={gameState.worldType}
|
||||
playerInventory={gameState.playerInventory}
|
||||
playerCurrency={gameState.playerCurrency}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
playerMaxMana={gameState.playerMaxMana}
|
||||
inBattle={gameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{shouldMountCampModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
|
||||
<CompanionCampModal
|
||||
isOpen={showTeamModal}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
companions={gameState.companions}
|
||||
roster={gameState.roster}
|
||||
inBattle={gameState.inBattle}
|
||||
onClose={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateCompanion={onActivateRosterCompanion}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountMapModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
|
||||
<MapModal
|
||||
isOpen={isMapOpen}
|
||||
currentScenePreset={gameState.currentScenePreset}
|
||||
worldType={gameState.worldType}
|
||||
canTravel={!gameState.inBattle && !isLoading}
|
||||
onTravelToScene={scene => {
|
||||
const triggered = handleMapTravelToScene(scene.id);
|
||||
if (triggered) {
|
||||
setIsMapOpen(false);
|
||||
}
|
||||
}}
|
||||
isTraveling={isLoading}
|
||||
onClose={() => setIsMapOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountCharacterChatModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
|
||||
<CharacterChatModal
|
||||
modal={characterChatUi.modal}
|
||||
onClose={characterChatUi.closeChat}
|
||||
onDraftChange={characterChatUi.setDraft}
|
||||
onUseSuggestion={characterChatUi.useSuggestion}
|
||||
onRefreshSuggestions={characterChatUi.refreshSuggestions}
|
||||
onSendDraft={characterChatUi.sendDraft}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountNpcModals && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载场景角色交互..." />}>
|
||||
<NpcModals gameState={gameState} npcUi={npcUi} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {lazy, Suspense} from 'react';
|
||||
|
||||
import type {SkillEffectPreviewProps} from './SkillEffectPreview';
|
||||
|
||||
const SkillEffectPreview = lazy(async () => {
|
||||
const module = await import('./SkillEffectPreview');
|
||||
|
||||
return {
|
||||
default: module.SkillEffectPreview,
|
||||
};
|
||||
});
|
||||
|
||||
function SkillEffectPreviewFallback() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="h-4 w-28 rounded bg-white/10" />
|
||||
<div className="h-3 w-40 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<div className="h-[300px] animate-pulse bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LazySkillEffectPreview(props: SkillEffectPreviewProps) {
|
||||
return (
|
||||
<Suspense fallback={<SkillEffectPreviewFallback />}>
|
||||
<SkillEffectPreview {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -9,9 +9,9 @@ import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -20,7 +20,6 @@ const authMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
@@ -31,9 +30,9 @@ vi.mock('../../services/authService', () => ({
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
@@ -76,8 +75,11 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
@@ -229,12 +228,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
@@ -242,9 +235,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus('unauthenticated');
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('clarification panel shows pending questions and ready state', () => {
|
||||
const pendingHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: 'player_premise',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const readyHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}}
|
||||
pendingClarifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(pendingHtml).toContain('待补充问题');
|
||||
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
||||
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
||||
});
|
||||
|
||||
test('falls back to stable keys when clarification ids are empty', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: '',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
label: '核心冲突',
|
||||
question: '第一阶段最直接撞上的冲突是什么?',
|
||||
targetKey: 'core_conflict',
|
||||
priority: 1,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy();
|
||||
expect(screen.getByText(/核心冲突/u)).toBeTruthy();
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldPendingClarification,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentClarificationPanelProps = {
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
readiness: CreatorIntentReadiness;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentClarificationPanel({
|
||||
pendingClarifications,
|
||||
readiness,
|
||||
}: CustomWorldAgentClarificationPanelProps) {
|
||||
if (readiness.isReady) {
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-emerald-300/18 bg-emerald-500/8 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-emerald-100/80">
|
||||
下一阶段
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
当前设定已齐备,可以进入下一阶段
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
待补充问题
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
先补最关键的 1 到 3 项
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
{pendingClarifications.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{pendingClarifications.slice(0, 3).map((item, index) => (
|
||||
<div
|
||||
key={item.id.trim() || `clarification-${item.targetKey}-${index}`}
|
||||
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{index + 1}. {item.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">P{item.priority}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{item.question}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
|
||||
|
||||
const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'publicMask',
|
||||
label: '外显身份',
|
||||
value: '守灯会里最熟悉旧航道的人。',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'publicMask', 'summary'],
|
||||
warningMessages: [],
|
||||
assetStatus: 'missing',
|
||||
assetStatusLabel: '待生成主图',
|
||||
};
|
||||
|
||||
function DetailInteractionHarness() {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
|
||||
null,
|
||||
);
|
||||
const [savedPayload, setSavedPayload] = useState<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={CHARACTER_DETAIL}
|
||||
loading={false}
|
||||
editMode={editMode}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
onCancelEdit={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
onSave={(sections) => {
|
||||
setSavedPayload(JSON.stringify(sections));
|
||||
setEditMode(false);
|
||||
}}
|
||||
onGenerateCharacter={() => {
|
||||
setGenerateMode('character');
|
||||
}}
|
||||
onGenerateLandmark={() => {
|
||||
setGenerateMode('landmark');
|
||||
}}
|
||||
onOpenRoleAssetStudio={() => {}}
|
||||
/>
|
||||
<CustomWorldGenerateEntityModal
|
||||
open={generateMode !== null}
|
||||
mode={generateMode ?? 'character'}
|
||||
anchorCardTitle={CHARACTER_DETAIL.title}
|
||||
onClose={() => {
|
||||
setGenerateMode(null);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setGenerateMode(null);
|
||||
}}
|
||||
/>
|
||||
<div data-testid="saved-payload">{savedPayload}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test('draft detail panel supports edit save and opening generate modals', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DetailInteractionHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||||
const summaryInput = screen.getByLabelText('角色摘要');
|
||||
await user.clear(summaryInput);
|
||||
await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(screen.getByTestId('saved-payload').textContent).toContain(
|
||||
'他像旧友,也像最早知道航道秘密的人。',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增角色' }));
|
||||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||||
expect(screen.getByText('当前参考卡')).toBeTruthy();
|
||||
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
|
||||
await user.click(closeButtons[closeButtons.length - 1]!);
|
||||
|
||||
expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增场景' }));
|
||||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
test('draft detail panel renders sections and warnings', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'thread-1',
|
||||
kind: 'thread',
|
||||
title: '谁掌握航道解释权',
|
||||
sections: [
|
||||
{
|
||||
id: 'thread-type',
|
||||
label: '线程类型',
|
||||
value: '明线',
|
||||
},
|
||||
{
|
||||
id: 'thread-conflict',
|
||||
label: '冲突内容',
|
||||
value: '守灯会与沉船商盟正在争夺航道解释权。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['character-1', 'landmark-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary', 'conflictType', 'stakes'],
|
||||
warningMessages: ['这条线还缺少更明确的地点挂点。'],
|
||||
}}
|
||||
loading={false}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {}}
|
||||
onGenerateCharacter={() => {}}
|
||||
onGenerateLandmark={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('谁掌握航道解释权');
|
||||
expect(html).toContain('线程类型');
|
||||
expect(html).toContain('守灯会与沉船商盟');
|
||||
expect(html).toContain('继续精修');
|
||||
expect(html).toContain('编辑设定');
|
||||
expect(html).toContain('新增角色');
|
||||
});
|
||||
|
||||
test('draft detail panel renders scene chapter label and background preview', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'scene-chapter-docks',
|
||||
kind: 'scene_chapter',
|
||||
title: '潮汐码头章节',
|
||||
sections: [
|
||||
{
|
||||
id: 'sceneName',
|
||||
label: '所属场景',
|
||||
value: '潮汐码头',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:backgroundImageSrc',
|
||||
label: '第 1 幕背景图',
|
||||
value: '/images/scene/docks-act-1.webp',
|
||||
},
|
||||
],
|
||||
linkedIds: ['landmark-docks', 'thread-smuggling'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary', 'act:act-docks-1:title'],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('场景章节');
|
||||
expect(html).toContain('第 1 幕背景图');
|
||||
expect(html).toContain('img');
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
|
||||
|
||||
type CustomWorldAgentDraftDetailPanelProps = {
|
||||
detail: CustomWorldDraftCardDetail | null;
|
||||
loading: boolean;
|
||||
busy?: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onStartEdit?: () => void;
|
||||
onCancelEdit?: () => void;
|
||||
onSave?: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onOpenRoleAssetStudio?: () => void;
|
||||
};
|
||||
|
||||
function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
|
||||
if (kind === 'world') return '世界总卡';
|
||||
if (kind === 'camp') return '营地';
|
||||
if (kind === 'faction') return '势力';
|
||||
if (kind === 'character') return '角色';
|
||||
if (kind === 'landmark') return '地点';
|
||||
if (kind === 'thread') return '线程';
|
||||
if (kind === 'chapter') return '第一幕';
|
||||
if (kind === 'scene_chapter') return '场景章节';
|
||||
return '草稿卡';
|
||||
}
|
||||
|
||||
function ActionButton(props: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky';
|
||||
}) {
|
||||
const { label, onClick, disabled = false, tone = 'default' } = props;
|
||||
|
||||
if (!onClick) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-1.5 text-[11px] transition ${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentDraftDetailPanel({
|
||||
detail,
|
||||
loading,
|
||||
busy = false,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldAgentDraftDetailPanelProps) {
|
||||
const shouldRenderImagePreview = (
|
||||
detailKind: CustomWorldDraftCardDetail['kind'],
|
||||
sectionId: string,
|
||||
value: string,
|
||||
) =>
|
||||
detailKind === 'scene_chapter' &&
|
||||
sectionId.endsWith(':backgroundImageSrc') &&
|
||||
value !== '待继续精修';
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
卡片详情
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-white/8 bg-white/5 px-4 py-5 text-sm leading-7 text-zinc-300">
|
||||
正在整理这张卡的内容。
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
{resolveKindLabel(detail.kind)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
关联 {detail.linkedIds.length}
|
||||
</span>
|
||||
{detail.editable ? (
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
可编辑
|
||||
</span>
|
||||
) : null}
|
||||
{detail.kind === 'character' && detail.assetStatusLabel ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
{detail.assetStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!editMode && detail.editable ? (
|
||||
<ActionButton
|
||||
label="编辑设定"
|
||||
onClick={onStartEdit}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{!editMode && detail.kind === 'character' ? (
|
||||
<ActionButton
|
||||
label="角色资产"
|
||||
onClick={onOpenRoleAssetStudio}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{!editMode ? (
|
||||
<>
|
||||
<ActionButton
|
||||
label="新增角色"
|
||||
onClick={onGenerateCharacter}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="新增场景"
|
||||
onClick={onGenerateLandmark}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editMode && onSave && onCancelEdit ? (
|
||||
<CustomWorldDraftEditPanel
|
||||
detail={detail}
|
||||
disabled={busy}
|
||||
onSave={onSave}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detail.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{section.label}
|
||||
</div>
|
||||
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
|
||||
<ResolvedAssetImage
|
||||
src={section.value}
|
||||
alt={section.label}
|
||||
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
|
||||
{section.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.warningMessages.length > 0 ? (
|
||||
<div className="rounded-[1.15rem] border border-amber-300/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-amber-100">
|
||||
继续精修
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.warningMessages.map((message, index) => (
|
||||
<div
|
||||
key={`${detail.id}-warning-${index}`}
|
||||
className="text-sm leading-7 text-amber-50"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm leading-7 text-zinc-400">
|
||||
从草稿抽屉里点开一张卡,就能在这里看世界底稿的具体内容。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentDraftDrawerProps = {
|
||||
draftCards: CustomWorldDraftCardSummary[];
|
||||
activeCardId?: string | null;
|
||||
onSelectCard: (cardId: string) => void;
|
||||
};
|
||||
|
||||
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
|
||||
'world',
|
||||
'chapter',
|
||||
'scene_chapter',
|
||||
'thread',
|
||||
'faction',
|
||||
'character',
|
||||
'landmark',
|
||||
'camp',
|
||||
];
|
||||
|
||||
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
|
||||
if (kind === 'world') return '世界总卡';
|
||||
if (kind === 'chapter') return '第一幕';
|
||||
if (kind === 'scene_chapter') return '场景章节';
|
||||
if (kind === 'thread') return '世界线程';
|
||||
if (kind === 'faction') return '势力';
|
||||
if (kind === 'character') return '关键角色';
|
||||
if (kind === 'landmark') return '关键地点';
|
||||
if (kind === 'camp') return '营地';
|
||||
return '草稿卡';
|
||||
}
|
||||
|
||||
export function CustomWorldAgentDraftDrawer({
|
||||
draftCards,
|
||||
activeCardId,
|
||||
onSelectCard,
|
||||
}: CustomWorldAgentDraftDrawerProps) {
|
||||
const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
|
||||
kind,
|
||||
items: draftCards.filter((card) => card.kind === kind),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
草稿抽屉
|
||||
</div>
|
||||
{groupedCards.length > 0 ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
{groupedCards.map((group) => (
|
||||
<section key={group.kind}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] tracking-[0.18em] text-zinc-400">
|
||||
{resolveGroupLabel(group.kind)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{group.items.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{group.items.map((card, index) => {
|
||||
const isActive = activeCardId === card.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card.id || `${group.kind}-card-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelectCard(card.id)}
|
||||
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/8 bg-white/5 hover:border-white/14'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
{card.warningCount > 0 ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
||||
{card.warningCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
{card.subtitle}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{card.summary}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
|
||||
关联 {card.linkedIds.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
|
||||
{card.status === 'warning' ? '待精修' : '建议稿'}
|
||||
</span>
|
||||
{card.kind === 'character' && card.assetStatusLabel ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
{card.assetStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
当前设定收束后,世界底稿会先从这里长出来。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
|
||||
|
||||
test('intent summary panel shows collected custom world anchors', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentIntentSummaryPanel
|
||||
creatorIntent={{
|
||||
sourceMode: 'freeform',
|
||||
rawSettingText: '',
|
||||
worldHook: '一个被潮雾切开的列岛世界。',
|
||||
themeKeywords: ['海岛'],
|
||||
toneDirectives: ['冷峻'],
|
||||
playerPremise: '玩家是失职返乡的守灯人。',
|
||||
openingSituation: '开局站在即将熄灭的旧灯塔上。',
|
||||
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
|
||||
keyCharacters: [],
|
||||
iconicElements: ['潮雾钟声'],
|
||||
}}
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: ['relationship_seed'],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('已收集设定');
|
||||
expect(html).toContain('世界一句话');
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('5/6');
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
hasMeaningfulCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
|
||||
type CustomWorldAgentIntentSummaryPanelProps = {
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
readiness: CreatorIntentReadiness;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentIntentSummaryPanel({
|
||||
creatorIntent,
|
||||
readiness,
|
||||
}: CustomWorldAgentIntentSummaryPanelProps) {
|
||||
const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
|
||||
const resolvedReadiness =
|
||||
readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
|
||||
const items = [
|
||||
{
|
||||
label: '世界一句话',
|
||||
value: intent?.worldHook || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('world_hook'),
|
||||
},
|
||||
{
|
||||
label: '玩家身份',
|
||||
value: intent?.playerPremise || '',
|
||||
ready: Boolean(intent?.playerPremise),
|
||||
},
|
||||
{
|
||||
label: '开局处境',
|
||||
value: intent?.openingSituation || '',
|
||||
ready: Boolean(intent?.openingSituation),
|
||||
},
|
||||
{
|
||||
label: '核心冲突',
|
||||
value: intent?.coreConflicts.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('core_conflict'),
|
||||
},
|
||||
{
|
||||
label: '主题气质',
|
||||
value:
|
||||
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
|
||||
.filter(Boolean)
|
||||
.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
|
||||
},
|
||||
{
|
||||
label: '标志性要素',
|
||||
value: intent?.iconicElements.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('iconic_element'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
已收集设定
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
{resolvedReadiness.completedKeys.length}/6
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasMeaningfulCustomWorldCreatorIntent(intent) ? (
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`rounded-[1.15rem] border px-3 py-3 ${
|
||||
item.ready
|
||||
? 'border-emerald-300/18 bg-emerald-500/8'
|
||||
: 'border-white/8 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-100">
|
||||
{item.value || '待补充'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
|
||||
还在收集你的世界设定
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type CustomWorldAgentLauncherModalProps = {
|
||||
isOpen: boolean;
|
||||
seedText: string;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSeedTextChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentLauncherModal({
|
||||
isOpen,
|
||||
seedText,
|
||||
isBusy,
|
||||
error,
|
||||
onClose,
|
||||
onSeedTextChange,
|
||||
onConfirm,
|
||||
}: CustomWorldAgentLauncherModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
开始和 Agent 共创
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
输入一段种子灵感,先进入新的工作区。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
Seed Text
|
||||
</div>
|
||||
<textarea
|
||||
value={seedText}
|
||||
onChange={(event) => onSeedTextChange(event.target.value)}
|
||||
rows={7}
|
||||
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
|
||||
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? '处理中...' : '开始共创'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
type CustomWorldAgentLockBarProps = {
|
||||
lockState: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function readLockedItems(lockState: Record<string, unknown> | null) {
|
||||
if (!lockState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(
|
||||
Object.entries(lockState)
|
||||
.flatMap(([key, value]) =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => `${key}:${item}`)
|
||||
: typeof value === 'string' && value.trim()
|
||||
? [`${key}:${value.trim()}`]
|
||||
: [],
|
||||
)
|
||||
)].slice(0, 8);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentLockBar({
|
||||
lockState,
|
||||
}: CustomWorldAgentLockBarProps) {
|
||||
const lockedItems = readLockedItems(lockState);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
锁定内容
|
||||
</div>
|
||||
{lockedItems.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lockedItems.map((item, index) => (
|
||||
<span
|
||||
key={`locked-item-${index}-${item}`}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
暂未锁定内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentQuickActionsProps = {
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
disabled: boolean;
|
||||
canDraftFoundation: boolean;
|
||||
showEntityActions?: boolean;
|
||||
showSummaryAction?: boolean;
|
||||
onRequestSummary: () => void;
|
||||
onDraftFoundation: () => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onGenerateRoleAssets?: () => void;
|
||||
showRoleAssetAction?: boolean;
|
||||
onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
|
||||
};
|
||||
|
||||
function QuickActionButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
tone?: 'default' | 'sky' | 'amber';
|
||||
}) {
|
||||
const { label, onClick, disabled, tone = 'default' } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-[1.1rem] border px-4 py-3 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
tone === 'amber'
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100 hover:text-white'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentQuickActions({
|
||||
suggestedActions,
|
||||
disabled,
|
||||
canDraftFoundation,
|
||||
showEntityActions = false,
|
||||
showSummaryAction = true,
|
||||
onRequestSummary,
|
||||
onDraftFoundation,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onGenerateRoleAssets,
|
||||
showRoleAssetAction = false,
|
||||
onFocusSuggestedAction,
|
||||
}: CustomWorldAgentQuickActionsProps) {
|
||||
const summaryAction = suggestedActions.find(
|
||||
(action) => action.type === 'request_summary',
|
||||
);
|
||||
const draftAction = suggestedActions.find(
|
||||
(action) => action.type === 'draft_foundation',
|
||||
);
|
||||
const refinementActions = suggestedActions.filter(
|
||||
(action) =>
|
||||
action.type !== 'request_summary' && action.type !== 'draft_foundation',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
快捷动作
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{showSummaryAction ? (
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{draftAction && canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={draftAction.label}
|
||||
onClick={onDraftFoundation}
|
||||
disabled={disabled}
|
||||
tone="amber"
|
||||
/>
|
||||
) : null}
|
||||
{showEntityActions && onGenerateCharacter ? (
|
||||
<QuickActionButton
|
||||
label="新增角色"
|
||||
onClick={onGenerateCharacter}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{showEntityActions && onGenerateLandmark ? (
|
||||
<QuickActionButton
|
||||
label="新增场景"
|
||||
onClick={onGenerateLandmark}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{showRoleAssetAction && onGenerateRoleAssets ? (
|
||||
<QuickActionButton
|
||||
label="生成角色主图与动作"
|
||||
onClick={onGenerateRoleAssets}
|
||||
disabled={disabled}
|
||||
tone="amber"
|
||||
/>
|
||||
) : null}
|
||||
{refinementActions.length > 0 ? (
|
||||
refinementActions.slice(0, 2).map((action) => (
|
||||
<QuickActionButton
|
||||
key={action.id}
|
||||
label={action.label}
|
||||
onClick={() => onFocusSuggestedAction(action)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))
|
||||
) : !draftAction || !canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
|
||||
onClick={() => onFocusSuggestedAction()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentSummaryPanelProps = {
|
||||
session: CustomWorldAgentSessionSnapshot;
|
||||
};
|
||||
|
||||
function readSummaryText(
|
||||
draftProfile: Record<string, unknown> | null,
|
||||
fallback: string,
|
||||
) {
|
||||
const title =
|
||||
typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
|
||||
const summary =
|
||||
typeof draftProfile?.summary === 'string'
|
||||
? draftProfile.summary.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: title || '世界摘要待整理',
|
||||
summary: summary || fallback,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWorldAgentSummaryPanel({
|
||||
session,
|
||||
}: CustomWorldAgentSummaryPanelProps) {
|
||||
const pendingCount = session.pendingClarifications.length;
|
||||
const { title, summary } = readSummaryText(
|
||||
session.draftProfile,
|
||||
'第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
顶部摘要
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
消息 {session.messages.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
待澄清 {pendingCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-300">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
type CustomWorldDraftCardDetailModalProps = {
|
||||
open: boolean;
|
||||
detail: CustomWorldDraftCardDetail | null;
|
||||
loading: boolean;
|
||||
busy?: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onStartEdit?: () => void;
|
||||
onCancelEdit?: () => void;
|
||||
onSave?: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onOpenRoleAssetStudio?: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldDraftCardDetailModal({
|
||||
open,
|
||||
detail,
|
||||
loading,
|
||||
busy = false,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldDraftCardDetailModalProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭卡片详情"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto">
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={detail}
|
||||
loading={loading}
|
||||
busy={busy}
|
||||
editMode={editMode}
|
||||
onClose={onClose}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSave={onSave}
|
||||
onGenerateCharacter={onGenerateCharacter}
|
||||
onGenerateLandmark={onGenerateLandmark}
|
||||
onOpenRoleAssetStudio={onOpenRoleAssetStudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
test('draft detail panel renders editable form in edit mode', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'publicMask',
|
||||
label: '外显身份',
|
||||
value: '守灯会里最熟悉旧航道的人。',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'publicMask', 'summary'],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
editMode
|
||||
onClose={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('保存');
|
||||
expect(html).toContain('取消');
|
||||
expect(html).toContain('角色名');
|
||||
expect(html).toContain('textarea');
|
||||
});
|
||||
|
||||
test('draft detail panel uses textarea for scene chapter act narrative fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'scene-chapter-docks',
|
||||
kind: 'scene_chapter',
|
||||
title: '潮汐码头章节',
|
||||
sections: [
|
||||
{
|
||||
id: 'title',
|
||||
label: '场景章节标题',
|
||||
value: '潮汐码头章节',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:summary',
|
||||
label: '第 1 幕摘要',
|
||||
value: '玩家刚抵达时,林潮先决定要不要放行。',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:encounterNpcIds',
|
||||
label: '第 1 幕相遇 NPC',
|
||||
value: '林潮\n晏九',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:transitionHook',
|
||||
label: '第 1 幕过渡钩子',
|
||||
value: '确认站位后,真正的封锁者会压上来。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-smuggling'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: [
|
||||
'title',
|
||||
'act:act-docks-1:summary',
|
||||
'act:act-docks-1:encounterNpcIds',
|
||||
'act:act-docks-1:transitionHook',
|
||||
],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
editMode
|
||||
onClose={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('第 1 幕摘要');
|
||||
expect(html).toContain('第 1 幕相遇 NPC');
|
||||
expect(html).toContain('第 1 幕过渡钩子');
|
||||
expect(html).toContain('textarea');
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldDraftEditPanelProps = {
|
||||
detail: CustomWorldDraftCardDetail;
|
||||
disabled?: boolean;
|
||||
onSave: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function shouldUseTextarea(sectionId: string, value: string) {
|
||||
const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null;
|
||||
return (
|
||||
value.length > 28 ||
|
||||
value.includes('\n') ||
|
||||
sectionId === 'summary' ||
|
||||
sectionId === 'tone' ||
|
||||
sectionId === 'coreConflicts' ||
|
||||
sectionId === 'hiddenHook' ||
|
||||
sectionId === 'secret' ||
|
||||
sectionId === 'stakes' ||
|
||||
sectionId === 'openingEvent' ||
|
||||
sectionId === 'understandingShift' ||
|
||||
sectionId === 'description' ||
|
||||
sceneActField === 'summary' ||
|
||||
sceneActField === 'encounterNpcIds' ||
|
||||
sceneActField === 'actGoal' ||
|
||||
sceneActField === 'transitionHook'
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldDraftEditPanel({
|
||||
detail,
|
||||
disabled = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CustomWorldDraftEditPanelProps) {
|
||||
const editableSections = useMemo(
|
||||
() =>
|
||||
detail.sections.filter((section) =>
|
||||
detail.editableSectionIds.includes(section.id),
|
||||
),
|
||||
[detail],
|
||||
);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
editableSections.map((section) => [section.id, section.value]),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValues(
|
||||
Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
|
||||
);
|
||||
}, [editableSections]);
|
||||
|
||||
if (editableSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{editableSections.map((section) => {
|
||||
const value = draftValues[section.id] ?? '';
|
||||
const multiline = shouldUseTextarea(section.id, value);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={section.id}
|
||||
className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{section.label}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraftValues((current) => ({
|
||||
...current,
|
||||
[section.id]: nextValue,
|
||||
}));
|
||||
}}
|
||||
rows={4}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraftValues((current) => ({
|
||||
...current,
|
||||
[section.id]: nextValue,
|
||||
}));
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="mt-2 h-11 w-full rounded-[0.9rem] border border-white/10 bg-black/26 px-3 text-sm text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(
|
||||
editableSections.map((section) => ({
|
||||
sectionId: section.id,
|
||||
value: draftValues[section.id] ?? '',
|
||||
})),
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type CustomWorldGenerateEntityModalProps = {
|
||||
open: boolean;
|
||||
mode: 'character' | 'landmark';
|
||||
anchorCardTitle?: string | null;
|
||||
disabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: {
|
||||
count: number;
|
||||
promptText: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldGenerateEntityModal({
|
||||
open,
|
||||
mode,
|
||||
anchorCardTitle,
|
||||
disabled = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CustomWorldGenerateEntityModalProps) {
|
||||
const [count, setCount] = useState(2);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(2);
|
||||
setPromptText('');
|
||||
}, [open, mode]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = mode === 'character' ? '新增角色' : '新增场景';
|
||||
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[96] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新增弹窗"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="platform-modal-shell platform-remap-surface relative z-10 w-full max-w-xl rounded-[1.8rem] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
AI 扩写
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{anchorCardTitle ? (
|
||||
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
当前参考卡
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-zinc-100">{anchorCardTitle}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">数量</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{[1, 2, 3].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setCount(value)}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||
count === value
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
补充要求
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(event) => setPromptText(event.target.value)}
|
||||
rows={5}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubmit({
|
||||
count,
|
||||
promptText: promptText.trim(),
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const baseDraftItem: CustomWorldWorkSummary = {
|
||||
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'object_refining',
|
||||
stageLabel: '精修对象',
|
||||
stageLabel: '待完善草稿',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
sessionId: 'session-1',
|
||||
@@ -35,7 +35,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onResumeDraft={() => {}}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
@@ -62,7 +62,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onResumeDraft={() => {}}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onResumeDraft={() => {}}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -15,14 +15,16 @@ type CustomWorldCreationHubProps = {
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
onCreateNew: () => void;
|
||||
onResumeDraft: (sessionId: string) => void;
|
||||
onOpenDraft: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
};
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-white">{title}</div>
|
||||
<div className="platform-subpanel flex min-h-[14rem] flex-col items-center justify-center rounded-[1.6rem] px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +36,7 @@ export function CustomWorldCreationHub({
|
||||
onBack,
|
||||
onRetry,
|
||||
onCreateNew,
|
||||
onResumeDraft,
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
@@ -52,35 +54,26 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div
|
||||
className="platform-remap-surface sticky top-0 z-20 -mx-3 px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--platform-modal-fill), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-white sm:text-[2.3rem]">
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2.3rem]">
|
||||
创作中心
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 sm:flex">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
草稿 {draftCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已发布 {publishedCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -98,12 +91,12 @@ export function CustomWorldCreationHub({
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/10 px-4 py-4 text-sm leading-7 text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
|
||||
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
@@ -115,15 +108,15 @@ export function CustomWorldCreationHub({
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
className="min-h-[12rem] rounded-[1.8rem] border border-white/8 bg-white/5 p-5"
|
||||
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
|
||||
>
|
||||
<div className="h-4 w-20 rounded-full bg-white/10" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-white/10" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-white/10" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-white/10" />
|
||||
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<div className="h-7 w-20 rounded-full bg-white/10" />
|
||||
<div className="h-7 w-20 rounded-full bg-white/10" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -135,12 +128,12 @@ export function CustomWorldCreationHub({
|
||||
key={item.workId}
|
||||
item={item}
|
||||
onClick={() => {
|
||||
if (item.status === 'draft' && item.sessionId) {
|
||||
onResumeDraft(item.sessionId);
|
||||
if (item.sourceType === 'agent_session' && item.sessionId) {
|
||||
onOpenDraft(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.status === 'published' && item.profileId) {
|
||||
if (item.profileId) {
|
||||
onEnterPublished(item.profileId);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type CustomWorldCreationLauncherModalProps = {
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'resume';
|
||||
seedText: string;
|
||||
seedTextLocked: boolean;
|
||||
questions: CustomWorldQuestion[];
|
||||
answers: Record<string, string>;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
lastError?: string | null;
|
||||
primaryLabel: string;
|
||||
onClose: () => void;
|
||||
onSeedTextChange: (value: string) => void;
|
||||
onAnswerChange: (questionId: string, value: string) => void;
|
||||
onPrimaryAction: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationLauncherModal({
|
||||
isOpen,
|
||||
mode,
|
||||
seedText,
|
||||
seedTextLocked,
|
||||
questions,
|
||||
answers,
|
||||
isBusy,
|
||||
error,
|
||||
lastError = null,
|
||||
primaryLabel,
|
||||
onClose,
|
||||
onSeedTextChange,
|
||||
onAnswerChange,
|
||||
onPrimaryAction,
|
||||
}: CustomWorldCreationLauncherModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
{mode === 'create' ? '新建作品' : '继续创作'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
输入一点灵感,开始共创一个新世界。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
世界灵感
|
||||
</div>
|
||||
<textarea
|
||||
value={seedText}
|
||||
onChange={(event) => onSeedTextChange(event.target.value)}
|
||||
rows={seedTextLocked ? 4 : 6}
|
||||
readOnly={seedTextLocked}
|
||||
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
|
||||
className={`w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 ${
|
||||
seedTextLocked ? 'cursor-not-allowed opacity-75' : ''
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{unansweredQuestions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-zinc-300">
|
||||
先补齐几条关键锚点,再开始生成。
|
||||
</div>
|
||||
{unansweredQuestions.map((question) => (
|
||||
<label key={question.id} className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
{question.label}
|
||||
</div>
|
||||
<div className="mb-2 text-xs leading-6 text-zinc-400">
|
||||
{question.question}
|
||||
</div>
|
||||
<textarea
|
||||
value={answers[question.id] ?? question.answer ?? ''}
|
||||
onChange={(event) =>
|
||||
onAnswerChange(question.id, event.target.value)
|
||||
}
|
||||
rows={3}
|
||||
placeholder="补充一句就可以。"
|
||||
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastError ? (
|
||||
<div className="rounded-3xl border border-amber-400/25 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
||||
上次生成未完成:{lastError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? '处理中...' : primaryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
onCreateNew: () => void;
|
||||
};
|
||||
@@ -8,35 +6,22 @@ export function CustomWorldCreationStartCard({
|
||||
onCreateNew,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.28),rgba(8,10,14,0.82))] px-5 py-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 18,
|
||||
paddingY: 11,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">新建作品</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="platform-button platform-button--primary w-full justify-between rounded-[1.1rem] text-left sm:w-auto"
|
||||
>
|
||||
<span className="text-sm font-semibold">新建作品</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -28,79 +27,79 @@ export function CustomWorldWorkCard({
|
||||
const isDraft = item.status === 'draft';
|
||||
const hasFoundationDraft =
|
||||
item.playableNpcCount > 0 || item.landmarkCount > 0;
|
||||
const actionLabel = isDraft
|
||||
? hasFoundationDraft
|
||||
? '继续完善'
|
||||
: '继续创作'
|
||||
: '进入世界';
|
||||
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 15,
|
||||
})}
|
||||
>
|
||||
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel={item.title.slice(0, 4) || '封面'}
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`rounded-full border px-3 py-1 text-[10px] tracking-[0.18em] ${
|
||||
className={`platform-pill px-3 py-1 text-[10px] ${
|
||||
isDraft
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||||
: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
? 'platform-pill--warm'
|
||||
: 'platform-pill--success'
|
||||
}`}
|
||||
>
|
||||
{isDraft ? '草稿' : '已发布'}
|
||||
</span>
|
||||
{item.stageLabel ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.stageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(item.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-black text-white">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-zinc-400">
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-zinc-200/90">
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{roleCountLabel} {item.playableNpcCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
地点 {item.landmarkCount}
|
||||
</span>
|
||||
{item.roleVisualReadyCount ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
|
||||
主图 {item.roleVisualReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.roleAnimationReadyCount ? (
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
|
||||
动作 {item.roleAnimationReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.roleAssetSummaryLabel ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.roleAssetSummaryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -108,9 +107,9 @@ export function CustomWorldWorkCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white"
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
@@ -37,10 +37,8 @@ export function CustomWorldWorkTabs({
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`shrink-0 rounded-full border px-4 py-2 text-sm transition ${
|
||||
activeFilter === option.id
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/18 text-zinc-300 hover:text-white'
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm ${
|
||||
activeFilter === option.id ? 'platform-tab--active' : ''
|
||||
}`}
|
||||
>
|
||||
{option.label} {count}
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
createCustomWorldAgentSession,
|
||||
executeCustomWorldAgentAction,
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
streamCustomWorldAgentMessage,
|
||||
} from '../../services/aiService';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
deleteCustomWorldProfile,
|
||||
getCustomWorldGalleryDetail,
|
||||
getProfileDashboard,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
upsertCustomWorldProfile,
|
||||
upsertProfileBrowseHistory,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
} from './PreGameSelectionFlow';
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return value ? `https://signed.example${value}` : '';
|
||||
}),
|
||||
isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value);
|
||||
}),
|
||||
}));
|
||||
|
||||
async function clickFirstButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = screen.getAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
async function clickFirstAsyncButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = await screen.findAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
createCustomWorldAgentSession: vi.fn(),
|
||||
executeCustomWorldAgentAction: vi.fn(),
|
||||
generateCustomWorldProfile: vi.fn(),
|
||||
getCustomWorldAgentOperation: vi.fn(),
|
||||
getCustomWorldAgentSession: vi.fn(),
|
||||
streamCustomWorldAgentMessage: 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('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||
CustomWorldAgentWorkspace: ({
|
||||
session,
|
||||
onExecuteAction,
|
||||
}: {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
onExecuteAction: (payload: { action: string }) => void;
|
||||
}) => (
|
||||
<div className="agent-workspace-mock">
|
||||
Agent工作区:{session?.sessionId ?? 'missing-session'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
>
|
||||
开始生成草稿
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
currentTurn: 0,
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
hook: '被海雾吞没的旧航路群岛。',
|
||||
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
|
||||
desiredExperience: '压抑、潮湿、悬疑',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: '玩家是被迫返乡的守灯人继承者。',
|
||||
corePursuit: '查清沉船夜与假航灯的关系。',
|
||||
fearOfLoss: '失去家族最后一条可信航线。',
|
||||
},
|
||||
themeBoundary: {
|
||||
toneKeywords: ['压抑', '悬疑'],
|
||||
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
|
||||
forbiddenDirectives: ['轻喜冒险'],
|
||||
},
|
||||
playerEntryPoint: {
|
||||
openingIdentity: '返乡守灯人继承者',
|
||||
openingProblem: '回港首夜撞见禁航区假航灯重亮',
|
||||
entryMotivation: '阻止更多船只误入死潮',
|
||||
},
|
||||
coreConflict: {
|
||||
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
|
||||
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
|
||||
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
|
||||
},
|
||||
keyRelationships: [
|
||||
{
|
||||
pairs: '玩家 vs 沈砺',
|
||||
relationshipType: '旧友互疑',
|
||||
secretOrCost: '他知道沉船夜的另一半真相',
|
||||
},
|
||||
],
|
||||
hiddenLines: {
|
||||
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
|
||||
misdirectionHints: ['表面像海雾自然失控'],
|
||||
revealPacing: '先见异常,再见旧案,再见操盘者',
|
||||
},
|
||||
iconicElements: {
|
||||
iconicMotifs: ['假航灯', '沉钟回响'],
|
||||
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
|
||||
hardRules: ['错误航灯会把船引进必死水域'],
|
||||
},
|
||||
},
|
||||
progressPercent: 0,
|
||||
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
|
||||
stage: 'clarifying',
|
||||
focusCardId: null,
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: [
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '先告诉我你想做一个怎样的 RPG 世界。',
|
||||
createdAt: '2026-04-14T12:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockAuthUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
type TestAuthValue = {
|
||||
user: AuthUser | null;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: 'light' | 'dark';
|
||||
setPlatformTheme: (theme: 'light' | 'dark') => void;
|
||||
isHydratingSettings: boolean;
|
||||
isPersistingSettings: boolean;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
|
||||
return {
|
||||
user: mockAuthUser,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
onContinueGame,
|
||||
}: {
|
||||
withAuth?: boolean;
|
||||
authValue?: TestAuthValue;
|
||||
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
const content = (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={{} as GameState}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={onContinueGame ?? (() => {})}
|
||||
handleStartNewGame={() => {}}
|
||||
handleCustomWorldSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!withAuth && !authValue) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={authValue ?? createAuthValue()}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
});
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
|
||||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||||
entry: {
|
||||
worldKey: 'custom:world-archive-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-archive-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T12:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {} as GameState,
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'agent-draft-custom-world-agent-session-1',
|
||||
profile: {
|
||||
id: 'agent-draft-custom-world-agent-session-1',
|
||||
name: '潮雾列岛',
|
||||
} as never,
|
||||
visibility: 'draft',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '第一版世界底稿已经整理完成。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
|
||||
operation: {
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'queued',
|
||||
phaseLabel: '已接收请求',
|
||||
phaseDetail: '正在准备生成世界底稿。',
|
||||
progress: 10,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||||
progress: 38,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(streamCustomWorldAgentMessage).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
|
||||
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
||||
const visualNovelButton = screen.getByRole('button', {
|
||||
name: /视觉小说/u,
|
||||
});
|
||||
|
||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCustomWorldAgentSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('clicking a public work while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'world-public-1',
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近公开发布的世界。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const workCards = await screen.findAllByRole('button', {
|
||||
name: /潮雾列岛/u,
|
||||
});
|
||||
await user.click(workCards[0]!);
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('platform home cards resolve generated cover images through signed read urls', async () => {
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'world-public-1',
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近公开发布的世界。',
|
||||
coverImageSrc: '/generated-custom-world-covers/world-public-1/cover.webp',
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
const coverImages = screen.getAllByAltText('潮雾列岛');
|
||||
expect(
|
||||
coverImages.some(
|
||||
(image) =>
|
||||
image.getAttribute('src') ===
|
||||
'https://signed.example/generated-custom-world-covers/world-public-1/cover.webp',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?customWorldSessionId=custom-world-agent-session-1',
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal,
|
||||
requireAuth: vi.fn(),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(getCustomWorldAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executeCustomWorldAgentAction).toHaveBeenCalledWith(
|
||||
'custom-world-agent-session-1',
|
||||
{
|
||||
action: 'draft_foundation',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
|
||||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||||
});
|
||||
|
||||
test('existing draft sessions enter the legacy result layout directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentSession).mockResolvedValue({
|
||||
...mockSession,
|
||||
stage: 'object_refining',
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
themeKeywords: ['海雾', '旧航路'],
|
||||
toneDirectives: ['压抑', '悬疑'],
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['会移动的海雾'],
|
||||
forbiddenDirectives: [],
|
||||
rawSettingText: '',
|
||||
},
|
||||
draftProfile: {
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '旧航路引路人',
|
||||
role: '关键同行者',
|
||||
publicIdentity: '最熟悉旧航路的人。',
|
||||
publicMask: '看上去像可靠旧友。',
|
||||
currentPressure: '他必须在两股势力间站队。',
|
||||
hiddenHook: '暗中替沉船商盟引路。',
|
||||
relationToPlayer: '旧友兼潜在背叛者',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
title: '守灯会值夜人',
|
||||
role: '场景关键角色',
|
||||
publicIdentity: '负责夜间巡灯与封锁。',
|
||||
publicMask: '对外一直冷静克制。',
|
||||
currentPressure: '她知道更多禁航区真相。',
|
||||
hiddenHook: '曾亲眼见过失控海雾吞船。',
|
||||
relationToPlayer: '最早愿意交换线索的人',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
purpose: '观察雾潮与往来船只',
|
||||
mood: '潮湿、压抑、风声不止',
|
||||
importance: '开局核心场景',
|
||||
characterIds: ['story-1'],
|
||||
threadIds: ['thread-1'],
|
||||
summary: '旧灯塔是整片群岛最先看见异动的地方。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [],
|
||||
chapters: [],
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
iconicElements: ['会移动的海雾'],
|
||||
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
|
||||
},
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
status: 'warning',
|
||||
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('世界档案', undefined, { timeout: 10000 }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy();
|
||||
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||||
|
||||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
||||
expect(screen.getByText('技能')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('authenticated users with save archives default into the saves tab', async () => {
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||||
});
|
||||
|
||||
test('save tab can resume a selected archive directly into the game', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleContinueGame = vi.fn();
|
||||
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T12:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as GameState,
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||||
expect(handleContinueGame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('owned world detail can delete a work and return to the create tab list', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-delete-1',
|
||||
profile: {
|
||||
id: 'world-delete-1',
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '用于测试删除流程的作品。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清旧案。',
|
||||
majorFactions: ['守灯会'],
|
||||
coreConflicts: ['雾潮正在逼近港口'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} as never,
|
||||
visibility: 'draft',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '用于测试删除流程的作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
},
|
||||
]);
|
||||
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteCustomWorldProfile).toHaveBeenCalledWith('world-delete-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。')
|
||||
.length,
|
||||
).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import {
|
||||
buildBodyPath,
|
||||
buildMedievalAtlasSpec,
|
||||
buildRaceAssetPath,
|
||||
clampMedievalAtlasFrame,
|
||||
getMedievalAtlasOptions,
|
||||
getMedievalPoseOptions,
|
||||
MEDIEVAL_BODY_COLORS,
|
||||
type MedievalAtlasSourceType,
|
||||
type MedievalNpcVisualOverride,
|
||||
type MedievalRace,
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import type { Encounter } from '../types';
|
||||
import { type NpcLayoutConfig, type NpcLayoutPart } from './npcVisualShared';
|
||||
|
||||
export type GearSourceType = 'none' | MedievalAtlasSourceType;
|
||||
|
||||
export type EditableNpcVisualState = {
|
||||
race: MedievalRace;
|
||||
bodyColor: string;
|
||||
headIndex: number;
|
||||
hairColorIndex: number;
|
||||
hairStyleFrame: number;
|
||||
facialHairEnabled: boolean;
|
||||
facialHairColorIndex: number;
|
||||
facialHairStyleFrame: number;
|
||||
headgearType: GearSourceType;
|
||||
headgearFile: string;
|
||||
headgearFrame: number;
|
||||
mainHandType: GearSourceType;
|
||||
mainHandFile: string;
|
||||
mainHandFrame: number;
|
||||
offHandType: GearSourceType;
|
||||
offHandFile: string;
|
||||
offHandFrame: number;
|
||||
};
|
||||
|
||||
export type EditorNpcOption = {
|
||||
encounter: Encounter;
|
||||
sceneNames: string[];
|
||||
};
|
||||
|
||||
const NPC_LAYOUT_PARTS: NpcLayoutPart[] = [
|
||||
'body',
|
||||
'head',
|
||||
'facialHair',
|
||||
'hair',
|
||||
'headgear',
|
||||
'hand',
|
||||
'mainHand',
|
||||
'offHand',
|
||||
];
|
||||
|
||||
export function sanitizeFrameSelection(
|
||||
type: GearSourceType,
|
||||
file: string,
|
||||
frame: number,
|
||||
usage: 'headgear' | 'mainHand' | 'offHand',
|
||||
) {
|
||||
if (type === 'none' || !file) return 0;
|
||||
const poseOptions = getMedievalPoseOptions(type, file, usage);
|
||||
if (poseOptions.length === 0) return 0;
|
||||
if (poseOptions.some(option => option.value === frame)) {
|
||||
return clampMedievalAtlasFrame(type, file, frame);
|
||||
}
|
||||
const firstOption = poseOptions[0];
|
||||
return firstOption ? firstOption.value : 0;
|
||||
}
|
||||
|
||||
export function getDefaultFileForType(type: GearSourceType) {
|
||||
if (type === 'none') return '';
|
||||
return getMedievalAtlasOptions(type)[0]?.file ?? '';
|
||||
}
|
||||
|
||||
export function getDefaultFrameForSelection(
|
||||
type: GearSourceType,
|
||||
file: string,
|
||||
usage: 'headgear' | 'mainHand' | 'offHand',
|
||||
) {
|
||||
if (type === 'none' || !file) return 0;
|
||||
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isNpcLayoutConfig(value: unknown): value is NpcLayoutConfig {
|
||||
return (
|
||||
isRecord(value)
|
||||
&& NPC_LAYOUT_PARTS.every(part => {
|
||||
const coordinate = value[part];
|
||||
return (
|
||||
isRecord(coordinate)
|
||||
&& typeof coordinate.x === 'number'
|
||||
&& Number.isFinite(coordinate.x)
|
||||
&& typeof coordinate.y === 'number'
|
||||
&& Number.isFinite(coordinate.y)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOverrideFromEditorState(
|
||||
state: EditableNpcVisualState,
|
||||
): MedievalNpcVisualOverride {
|
||||
return {
|
||||
race: state.race,
|
||||
bodySrc: buildBodyPath(
|
||||
state.bodyColor as (typeof MEDIEVAL_BODY_COLORS)[number],
|
||||
),
|
||||
headSrc: buildRaceAssetPath(state.race, 'head', state.headIndex),
|
||||
hairSrc: buildRaceAssetPath(state.race, 'hair', state.hairColorIndex),
|
||||
handSrc: buildRaceAssetPath(state.race, 'hand', 1),
|
||||
facialHairSrc: state.facialHairEnabled
|
||||
? buildRaceAssetPath(state.race, 'facialHair', state.facialHairColorIndex)
|
||||
: undefined,
|
||||
headgear:
|
||||
state.headgearType === 'none'
|
||||
? undefined
|
||||
: buildMedievalAtlasSpec(
|
||||
state.headgearType,
|
||||
state.headgearFile,
|
||||
sanitizeFrameSelection(
|
||||
state.headgearType,
|
||||
state.headgearFile,
|
||||
state.headgearFrame,
|
||||
'headgear',
|
||||
),
|
||||
),
|
||||
mainHand:
|
||||
state.mainHandType === 'none'
|
||||
? undefined
|
||||
: buildMedievalAtlasSpec(
|
||||
state.mainHandType,
|
||||
state.mainHandFile,
|
||||
sanitizeFrameSelection(
|
||||
state.mainHandType,
|
||||
state.mainHandFile,
|
||||
state.mainHandFrame,
|
||||
'mainHand',
|
||||
),
|
||||
),
|
||||
offHand:
|
||||
state.offHandType === 'none'
|
||||
? undefined
|
||||
: buildMedievalAtlasSpec(
|
||||
state.offHandType,
|
||||
state.offHandFile,
|
||||
sanitizeFrameSelection(
|
||||
state.offHandType,
|
||||
state.offHandFile,
|
||||
state.offHandFrame,
|
||||
'offHand',
|
||||
),
|
||||
),
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
headFrame: 0,
|
||||
hairFrame: state.hairStyleFrame,
|
||||
handFrame: 0,
|
||||
facialHairFrame: state.facialHairEnabled
|
||||
? state.facialHairStyleFrame
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNpcVisualSavePayload(
|
||||
overrideMap: Record<string, MedievalNpcVisualOverride>,
|
||||
npcId: string,
|
||||
editorState: EditableNpcVisualState,
|
||||
) {
|
||||
return {
|
||||
...overrideMap,
|
||||
[npcId]: buildOverrideFromEditorState(editorState),
|
||||
};
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
|
||||
import type { EditableNpcVisualState } from './npcVisualEditorModel';
|
||||
import {
|
||||
NPC_LAYOUT_CONFIG_API_PATH,
|
||||
NPC_VISUAL_OVERRIDES_API_PATH,
|
||||
persistNpcLayoutConfig,
|
||||
persistNpcVisualOverrides,
|
||||
} from './npcVisualEditorPersistence';
|
||||
import type { NpcLayoutConfig } from './npcVisualShared';
|
||||
|
||||
function createEditorState(): EditableNpcVisualState {
|
||||
return {
|
||||
race: 'human',
|
||||
bodyColor: 'black',
|
||||
headIndex: 1,
|
||||
hairColorIndex: 1,
|
||||
hairStyleFrame: 0,
|
||||
facialHairEnabled: false,
|
||||
facialHairColorIndex: 1,
|
||||
facialHairStyleFrame: 0,
|
||||
headgearType: 'none',
|
||||
headgearFile: '',
|
||||
headgearFrame: 0,
|
||||
mainHandType: 'none',
|
||||
mainHandFile: '',
|
||||
mainHandFrame: 0,
|
||||
offHandType: 'none',
|
||||
offHandFile: '',
|
||||
offHandFrame: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createExistingOverride(): MedievalNpcVisualOverride {
|
||||
return {
|
||||
race: 'elf',
|
||||
bodySrc: '/body.png',
|
||||
headSrc: '/head.png',
|
||||
hairSrc: '/hair.png',
|
||||
handSrc: '/hand.png',
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
headFrame: 0,
|
||||
hairFrame: 1,
|
||||
handFrame: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createLayoutDraft(): NpcLayoutConfig {
|
||||
return {
|
||||
body: { x: 0, y: 0 },
|
||||
head: { x: 1, y: 2 },
|
||||
facialHair: { x: 3, y: 4 },
|
||||
hair: { x: 5, y: 6 },
|
||||
headgear: { x: 7, y: 8 },
|
||||
hand: { x: 9, y: 10 },
|
||||
mainHand: { x: 11, y: 12 },
|
||||
offHand: { x: 13, y: 14 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('npcVisualEditorPersistence', () => {
|
||||
it('persists merged npc visual overrides and returns the writeback payload', async () => {
|
||||
const saveJson = vi.fn(async () => undefined);
|
||||
const result = await persistNpcVisualOverrides({
|
||||
overrideMap: {
|
||||
existing: createExistingOverride(),
|
||||
},
|
||||
npcId: 'npc-1',
|
||||
editorState: createEditorState(),
|
||||
saveJson,
|
||||
});
|
||||
|
||||
expect(saveJson).toHaveBeenCalledWith(
|
||||
NPC_VISUAL_OVERRIDES_API_PATH,
|
||||
expect.objectContaining({
|
||||
existing: createExistingOverride(),
|
||||
'npc-1': expect.objectContaining({
|
||||
race: 'human',
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
}),
|
||||
}),
|
||||
'保存角色形象覆盖配置失败',
|
||||
);
|
||||
expect(result.nextOverrideMap.existing).toEqual(createExistingOverride());
|
||||
expect(result.nextOverrideMap['npc-1']).toEqual(expect.objectContaining({ race: 'human' }));
|
||||
expect(result.saveMessage).toContain('npcVisualOverrides.json');
|
||||
});
|
||||
|
||||
it('persists layout config with a cloned payload for local writeback', async () => {
|
||||
const saveJson = vi.fn(async () => undefined);
|
||||
const layoutDraft = createLayoutDraft();
|
||||
const result = await persistNpcLayoutConfig({
|
||||
layoutDraft,
|
||||
saveJson,
|
||||
});
|
||||
|
||||
expect(saveJson).toHaveBeenCalledWith(
|
||||
NPC_LAYOUT_CONFIG_API_PATH,
|
||||
expect.objectContaining(layoutDraft),
|
||||
'保存角色布局配置失败',
|
||||
);
|
||||
expect(result.nextLayout).toEqual(layoutDraft);
|
||||
expect(result.nextLayout).not.toBe(layoutDraft);
|
||||
expect(result.saveMessage).toContain('角色布局');
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
|
||||
import {
|
||||
buildEditorJsonApiPath,
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { saveJsonObject } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
buildNpcVisualSavePayload,
|
||||
type EditableNpcVisualState,
|
||||
} from './npcVisualEditorModel';
|
||||
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
|
||||
|
||||
export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides,
|
||||
);
|
||||
export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig,
|
||||
);
|
||||
|
||||
type SaveEditorJsonFn = typeof saveJsonObject;
|
||||
|
||||
export async function persistNpcVisualOverrides(params: {
|
||||
overrideMap: Record<string, MedievalNpcVisualOverride>;
|
||||
npcId: string;
|
||||
editorState: EditableNpcVisualState;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const {
|
||||
overrideMap,
|
||||
npcId,
|
||||
editorState,
|
||||
saveJson = saveJsonObject,
|
||||
} = params;
|
||||
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
|
||||
|
||||
await saveJson(
|
||||
NPC_VISUAL_OVERRIDES_API_PATH,
|
||||
nextOverrideMap,
|
||||
'保存角色形象覆盖配置失败',
|
||||
);
|
||||
|
||||
return {
|
||||
nextOverrideMap,
|
||||
saveMessage: '已将角色形象覆盖配置保存到 src/data/npcVisualOverrides.json。',
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistNpcLayoutConfig(params: {
|
||||
layoutDraft: NpcLayoutConfig;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const { layoutDraft, saveJson = saveJsonObject } = params;
|
||||
const nextLayout = cloneNpcLayoutConfig(layoutDraft);
|
||||
|
||||
await saveJson(
|
||||
NPC_LAYOUT_CONFIG_API_PATH,
|
||||
nextLayout,
|
||||
'保存角色布局配置失败',
|
||||
);
|
||||
|
||||
return {
|
||||
nextLayout,
|
||||
saveMessage: '已保存共享角色布局配置。',
|
||||
};
|
||||
}
|
||||
@@ -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,110 +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';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
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;
|
||||
@@ -528,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;
|
||||
@@ -557,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>(
|
||||
() => ({
|
||||
@@ -638,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
|
||||
@@ -912,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, {
|
||||
@@ -955,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]) {
|
||||
@@ -982,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;
|
||||
@@ -1057,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) =>
|
||||
@@ -1169,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 ? (
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={workingRole.name}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
<ResolvedAssetImage
|
||||
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 ? (
|
||||
<ResolvedAssetImage
|
||||
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,186 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { ImagePlus, RefreshCcw } from 'lucide-react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
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 ? (
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={workingRoleName}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplatePortrait ? (
|
||||
<ResolvedAssetImage
|
||||
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;
|
||||
@@ -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 { WorldEditor as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import RpgCreationEntityEditorModalImpl from './RpgCreationEntityEditorModalImpl';
|
||||
import type {
|
||||
RpgCreationEditorTarget,
|
||||
} from './RpgCreationEntityEditorModalImpl';
|
||||
|
||||
/**
|
||||
* 工作包 C 完成后,编辑器 façade 已直接桥接 RPG 创作目录下的目标分发壳层。
|
||||
* 旧 `CustomWorldEntityEditorModal.tsx` 兼容入口已经删除,当前继续保留 shared 实现承载复杂表单细节,
|
||||
* 后续可在不改入口的前提下继续物理拆分 section。
|
||||
*/
|
||||
export type RpgCreationEntityEditorModalProps = ComponentProps<
|
||||
typeof RpgCreationEntityEditorModalImpl
|
||||
>;
|
||||
|
||||
export function RpgCreationEntityEditorModal(
|
||||
props: RpgCreationEntityEditorModalProps,
|
||||
) {
|
||||
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,44 +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 { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
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,
|
||||
@@ -64,31 +61,39 @@ 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';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
} from '../game-canvas/GameCanvasShared';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
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' }
|
||||
@@ -99,9 +104,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;
|
||||
}
|
||||
@@ -1146,6 +1151,7 @@ function ImagePreview({
|
||||
fallbackLabel,
|
||||
tone = 'square',
|
||||
children,
|
||||
previewOverlay,
|
||||
overlayInteractive = false,
|
||||
}: {
|
||||
src?: string;
|
||||
@@ -1153,34 +1159,30 @@ function ImagePreview({
|
||||
fallbackLabel: string;
|
||||
tone?: 'square' | 'landscape';
|
||||
children?: ReactNode;
|
||||
previewOverlay?: ReactNode;
|
||||
overlayInteractive?: boolean;
|
||||
}) {
|
||||
const {
|
||||
resolvedUrl: resolvedSrc,
|
||||
shouldResolve,
|
||||
} = useResolvedAssetReadUrl(src);
|
||||
const displaySrc = resolvedSrc || (!shouldResolve ? src : '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{displaySrc ? (
|
||||
<img
|
||||
src={displaySrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
>
|
||||
{src ? (
|
||||
<ResolvedAssetImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
)}
|
||||
{children ? (
|
||||
{children || previewOverlay ? (
|
||||
<div
|
||||
className={`${overlayInteractive ? 'pointer-events-auto' : 'pointer-events-none'} absolute inset-0`}
|
||||
>
|
||||
{previewOverlay}
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1745,11 +1747,11 @@ function SceneActPreviewRuntime({
|
||||
resetGame,
|
||||
handleCustomWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = useGameFlow();
|
||||
} = useRpgSessionBootstrap();
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
const storyFlow = useStoryGeneration({
|
||||
const storyFlow = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
@@ -1845,7 +1847,7 @@ function SceneActPreviewRuntime({
|
||||
}
|
||||
|
||||
return (
|
||||
<GameShellRuntime
|
||||
<RpgRuntimeShell
|
||||
session={{
|
||||
gameState,
|
||||
currentStory: storyFlow.currentStory,
|
||||
@@ -2469,7 +2471,7 @@ function SceneImageGenerationModal({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await generateCustomWorldSceneImage({
|
||||
const result = await rpgCreationAssetClient.generateSceneImage({
|
||||
profile,
|
||||
landmark,
|
||||
userPrompt,
|
||||
@@ -3157,7 +3159,7 @@ function CoverImageGenerationModal({
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCoverEditor({
|
||||
export function WorldCoverEditor({
|
||||
profile,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
@@ -3366,7 +3368,7 @@ function WorldCoverEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
export function SaveBar({
|
||||
onClose,
|
||||
onSave,
|
||||
extraAction,
|
||||
@@ -3412,7 +3414,7 @@ function SaveBar({
|
||||
);
|
||||
}
|
||||
|
||||
function SectionPanel({
|
||||
export function SectionPanel({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
@@ -3889,7 +3891,7 @@ function RoleSkillEditorModal({
|
||||
/>
|
||||
</div>
|
||||
) : role.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
<img
|
||||
src={role.imageSrc}
|
||||
alt={role.name}
|
||||
className="max-h-40 w-full object-contain"
|
||||
@@ -4021,7 +4023,7 @@ function SkillListEditor({
|
||||
/>
|
||||
</div>
|
||||
) : role.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
<img
|
||||
src={role.imageSrc}
|
||||
alt={skill.name}
|
||||
className="max-h-20 w-full object-contain"
|
||||
@@ -4419,7 +4421,7 @@ function StoryNpcVisualEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
function WorldEditor({
|
||||
export function WorldEditor({
|
||||
profile,
|
||||
onSave,
|
||||
onClose,
|
||||
@@ -4510,7 +4512,7 @@ function WorldEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function CampSceneEditor({
|
||||
export function CampSceneEditor({
|
||||
profile,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
@@ -4531,7 +4533,7 @@ function CampSceneEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function PlayableNpcEditor({
|
||||
export function PlayableNpcEditor({
|
||||
profile,
|
||||
npc,
|
||||
mode,
|
||||
@@ -4597,7 +4599,7 @@ function PlayableNpcEditor({
|
||||
</div>
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
||||
<ResolvedAssetImage
|
||||
<img
|
||||
src={draft.imageSrc || selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
className="h-28 w-full object-cover object-top"
|
||||
@@ -4790,7 +4792,7 @@ function PlayableNpcEditor({
|
||||
showClose={false}
|
||||
/>
|
||||
{isAiAssetStudioOpen ? (
|
||||
<CustomWorldRoleAssetStudioModal
|
||||
<RpgCreationRoleAssetStudioModal
|
||||
role={draft}
|
||||
roleKind="playable"
|
||||
onApply={(nextRole) =>
|
||||
@@ -4835,7 +4837,7 @@ function PlayableNpcEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function StoryNpcEditor({
|
||||
export function StoryNpcEditor({
|
||||
profile,
|
||||
npc,
|
||||
mode,
|
||||
@@ -5086,7 +5088,7 @@ function StoryNpcEditor({
|
||||
/>
|
||||
) : null}
|
||||
{isAiAssetStudioOpen ? (
|
||||
<CustomWorldRoleAssetStudioModal
|
||||
<RpgCreationRoleAssetStudioModal
|
||||
role={draft}
|
||||
roleKind="story"
|
||||
onApply={(nextRole) =>
|
||||
@@ -5131,7 +5133,7 @@ function StoryNpcEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function LandmarkEditor({
|
||||
export function LandmarkEditor({
|
||||
profile,
|
||||
landmark,
|
||||
mode,
|
||||
@@ -5916,238 +5918,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') {
|
||||
@@ -6181,102 +5957,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,314 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
|
||||
type RpgCreationAssetDebugEntry = {
|
||||
id: string;
|
||||
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
readUrl?: 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>
|
||||
>({});
|
||||
const [assetDebugReadUrlMap, setAssetDebugReadUrlMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
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');
|
||||
void resolveAssetReadUrl(entry.imageSrc)
|
||||
.then((readUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetDebugReadUrlMap((current) => ({
|
||||
...current,
|
||||
[entry.id]: readUrl,
|
||||
}));
|
||||
image.src = readUrl;
|
||||
})
|
||||
.catch(() => {
|
||||
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) => {
|
||||
const readUrl = assetDebugReadUrlMap[entry.id] || entry.imageSrc;
|
||||
|
||||
return (
|
||||
<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={readUrl}
|
||||
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;
|
||||
17
src/components/rpg-creation-result/RpgCreationResultView.tsx
Normal file
17
src/components/rpg-creation-result/RpgCreationResultView.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { RpgCreationResultView as RpgCreationResultViewImpl } from './RpgCreationResultViewImpl';
|
||||
|
||||
/**
|
||||
* 工作包 C 完成后,结果页入口统一桥接 RPG 创作目录下的真实实现。
|
||||
* 旧 `CustomWorldResultView.tsx` 兼容入口已经删除,后续结果页细化继续在该目录内部推进。
|
||||
*/
|
||||
export type RpgCreationResultViewProps = ComponentProps<
|
||||
typeof RpgCreationResultViewImpl
|
||||
>;
|
||||
|
||||
export function RpgCreationResultView(props: RpgCreationResultViewProps) {
|
||||
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,12 +11,17 @@ 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 {ResolvedAssetImage} from '../ResolvedAssetImage';
|
||||
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 { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { CharacterDraftModal } from '../SelectionCustomizationModals';
|
||||
|
||||
type CharacterSelectionDraft = {
|
||||
name: string;
|
||||
@@ -25,19 +30,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']) {
|
||||
@@ -169,12 +202,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],
|
||||
@@ -473,3 +506,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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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;
|
||||
@@ -19,23 +19,28 @@ import {
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { type ComponentType, useMemo } from 'react';
|
||||
import {
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileSaveArchiveSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformBrandLogo } from './PlatformBrandLogo';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
@@ -43,9 +48,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 =
|
||||
@@ -54,6 +87,47 @@ const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
|
||||
function usePlatformDesktopLayout() {
|
||||
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
|
||||
const updateLayout = (event?: MediaQueryListEvent) => {
|
||||
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
|
||||
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', updateLayout);
|
||||
return () => mediaQuery.removeEventListener('change', updateLayout);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(updateLayout);
|
||||
return () => mediaQuery.removeListener(updateLayout);
|
||||
}, []);
|
||||
|
||||
return isDesktopLayout;
|
||||
}
|
||||
|
||||
function ResolvedAssetBackdrop({
|
||||
src,
|
||||
@@ -66,17 +140,9 @@ function ResolvedAssetBackdrop({
|
||||
className: string;
|
||||
ariaHidden?: boolean;
|
||||
}) {
|
||||
const { resolvedUrl, shouldResolve } = useResolvedAssetReadUrl(src);
|
||||
// 私有 OSS 封面在签名地址返回前保持现有底色层,避免浏览器直接访问旧 generated-* 路径。
|
||||
const displaySrc = resolvedUrl || (!shouldResolve ? src?.trim() ?? '' : '');
|
||||
|
||||
if (!displaySrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={displaySrc}
|
||||
<ResolvedAssetImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
aria-hidden={ariaHidden}
|
||||
className={className}
|
||||
@@ -660,7 +726,7 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformHomeView({
|
||||
export function RpgEntryHomeView({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
hasSavedGame,
|
||||
@@ -684,35 +750,11 @@ export function PlatformHomeView({
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onOpenProfileDashboardCard,
|
||||
}: {
|
||||
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,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
@@ -763,7 +805,7 @@ export function PlatformHomeView({
|
||||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
|
||||
let content = (
|
||||
let content: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -855,59 +897,62 @@ export function PlatformHomeView({
|
||||
);
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
content =
|
||||
createTabContent ?? (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
开启新的创作
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf
|
||||
text={
|
||||
isAuthenticated
|
||||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||||
: '登录后查看你的作品。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf
|
||||
text={
|
||||
isAuthenticated
|
||||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||||
: '登录后查看你的作品。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'saves') {
|
||||
@@ -1148,7 +1193,7 @@ export function PlatformHomeView({
|
||||
);
|
||||
}
|
||||
|
||||
const desktopContent =
|
||||
const desktopContent: ReactNode =
|
||||
activeTab === 'home' ? (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
{platformError ? (
|
||||
@@ -1446,11 +1491,11 @@ export function PlatformHomeView({
|
||||
content
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col lg:hidden">
|
||||
if (!isDesktopLayout) {
|
||||
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">
|
||||
@@ -1492,12 +1537,16 @@ export function PlatformHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="hidden h-full min-h-0 lg:flex lg:flex-col">
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<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">
|
||||
@@ -1578,3 +1627,5 @@ export function PlatformHomeView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlatformHomeView = RpgEntryHomeView;
|
||||
@@ -10,7 +10,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,
|
||||
@@ -42,7 +54,7 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformWorldDetailView({
|
||||
export function RpgEntryWorldDetailView({
|
||||
entry,
|
||||
isMutating,
|
||||
error,
|
||||
@@ -52,19 +64,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);
|
||||
@@ -239,9 +242,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
|
||||
@@ -287,3 +291,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,
|
||||
};
|
||||
}
|
||||
476
src/components/rpg-entry/useRpgEntryLibraryDetail.ts
Normal file
476
src/components/rpg-entry/useRpgEntryLibraryDetail.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
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 { ApiClientError } from '../../services/apiClient';
|
||||
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;
|
||||
};
|
||||
|
||||
function isMissingRpgEntryAgentSessionError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 404 &&
|
||||
error.code === 'NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责平台详情、创作作品入口和结果页打开路径。
|
||||
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
|
||||
*/
|
||||
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) {
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
resetAutoSaveTrackingToIdle();
|
||||
|
||||
const shouldOpenAgentWorkspace =
|
||||
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
|
||||
|
||||
try {
|
||||
if (shouldOpenAgentWorkspace) {
|
||||
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
|
||||
suppressAgentDraftResultAutoOpen();
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAgentDraftResultAutoOpenSuppression();
|
||||
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
|
||||
const nextProfile = buildDraftResultProfile(latestSession);
|
||||
if (!nextProfile) {
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(
|
||||
normalizeRpgEntryAgentBackedProfile(nextProfile),
|
||||
);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('custom-world-result');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isMissingRpgEntryAgentSessionError(error)) {
|
||||
// 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。
|
||||
persistAgentUiState(null, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setPlatformError(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
);
|
||||
} else {
|
||||
setPlatformError(
|
||||
resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('platform');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user