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:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

@@ -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} />;
}

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ import {
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
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;

View File

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

View File

@@ -6,13 +6,35 @@ import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import { CustomWorldResultView } from './CustomWorldResultView';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
vi.mock('../services/aiService', () => ({
generateCustomWorldPlayableNpc: vi.fn(),
generateCustomWorldStoryNpc: vi.fn(),
generateCustomWorldLandmark: vi.fn(),
}));
vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
const generatePlayableNpc = vi.fn();
const generateStoryNpc = vi.fn();
const generateLandmark = vi.fn();
const generateSceneImage = vi.fn();
const generateSceneNpc = vi.fn();
return {
rpgCreationAssetClient: {
generatePlayableNpc,
generateStoryNpc,
generateLandmark,
generateSceneImage,
generateSceneNpc,
},
generateCustomWorldPlayableNpc: generatePlayableNpc,
generateCustomWorldStoryNpc: generateStoryNpc,
generateCustomWorldLandmark: generateLandmark,
generateCustomWorldSceneImage: generateSceneImage,
generateCustomWorldSceneNpc: generateSceneNpc,
};
});
const mockedRpgCreationAssetClient = vi.mocked(
rpgCreationAssetClient.rpgCreationAssetClient,
);
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
@@ -24,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);
});

View File

@@ -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>
);
}

View File

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

View File

@@ -1,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>
);
}

View File

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

View File

@@ -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';

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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>
);
}

View File

@@ -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();
});

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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={() => {}}
/>,
);

View File

@@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => {
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -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);
}
}}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { ImagePlus, RefreshCcw } from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
@@ -9,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>
);

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

@@ -4,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;

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildCharacterAttributeProfile,
@@ -11,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;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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;

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More