1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-14 21:49:44 +08:00
parent fa435aa6a6
commit 6363267bca
13 changed files with 2743 additions and 237 deletions

View File

@@ -13,7 +13,17 @@ interface CustomWorldGenerationViewProps {
onBack: () => void;
onEditSetting: () => void;
onRetry: () => void;
onInterrupt: () => void;
onInterrupt?: () => void;
backLabel?: string;
settingActionLabel?: string;
retryLabel?: string;
interruptLabel?: string;
settingTitle?: string;
settingDescription?: string;
progressTitle?: string;
activeBadgeLabel?: string;
pausedBadgeLabel?: string;
idleBadgeLabel?: string;
}
function formatDuration(ms: number) {
@@ -46,6 +56,16 @@ export function CustomWorldGenerationView({
onEditSetting,
onRetry,
onInterrupt,
backLabel = '返回',
settingActionLabel = '修改设定',
retryLabel = '重新开始生成',
interruptLabel = '中断世界生成',
settingTitle = '玩家设定',
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
progressTitle = '生成进度',
activeBadgeLabel = '世界建设中',
pausedBadgeLabel = '生成已暂停',
idleBadgeLabel = '等待操作',
}: CustomWorldGenerationViewProps) {
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
@@ -68,10 +88,14 @@ export function CustomWorldGenerationView({
disabled={isGenerating}
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 ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
>
{backLabel}
</button>
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
{isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'}
{isGenerating
? activeBadgeLabel
: error
? pausedBadgeLabel
: idleBadgeLabel}
</div>
</div>
@@ -86,10 +110,10 @@ export function CustomWorldGenerationView({
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
</div>
<button
@@ -98,7 +122,7 @@ export function CustomWorldGenerationView({
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
</div>
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
@@ -116,7 +140,7 @@ export function CustomWorldGenerationView({
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
{progressTitle}
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
@@ -211,7 +235,7 @@ export function CustomWorldGenerationView({
onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
{settingActionLabel}
</button>
<button
type="button"
@@ -224,21 +248,21 @@ export function CustomWorldGenerationView({
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
{retryLabel}
</span>
<span className="text-white/60"></span>
</div>
</button>
</>
) : (
) : onInterrupt ? (
<button
type="button"
onClick={onInterrupt}
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
>
{interruptLabel}
</button>
)}
) : null}
</div>
</section>
</div>

View File

@@ -273,9 +273,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
gameState.currentScene === 'Selection' &&
Boolean(gameState.worldType) &&
!gameState.playerCharacter;
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
selectionStage !== 'platform';
const collapseTopStage = gameState.currentScene === 'Selection';
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const dialogueIndicator = useMemo(() => {
@@ -375,15 +373,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
backgroundRepeat: 'repeat',
}}
>
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
<div className="text-center">
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl"></div>
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base"> RPG</div>
</div>
</div>
) : (
<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}

View File

@@ -31,14 +31,7 @@ export function GameShellCanvasStage({
}) {
return (
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
<div className="selection-hero-brand px-6 text-center">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
</div>
) : (
{hideSelectionHero ? null : (
<GameCanvas
scrollWorld={visibleGameState.scrollWorld}
animationState={visibleGameState.animationState}

View File

@@ -0,0 +1,158 @@
import { X } from 'lucide-react';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type PlatformCreationTypeModalProps = {
isOpen: boolean;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSelectRpg: () => void;
};
type CreationGameTypeCard = {
id: 'rpg' | 'airp' | 'visual-novel';
title: string;
subtitle: string;
badge: string;
locked: boolean;
};
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
{
id: 'rpg',
title: '角色扮演 RPG',
subtitle: 'Agent 共创',
badge: '可创建',
locked: false,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
];
function CreationTypeCard(props: {
item: CreationGameTypeCard;
busy: boolean;
onSelect: () => void;
}) {
const { item, busy, onSelect } = props;
const disabled = item.locked || busy;
return (
<button
type="button"
disabled={disabled}
onClick={onSelect}
className={`relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left transition ${
item.locked
? 'cursor-not-allowed border-white/8 bg-white/5 text-zinc-500'
: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.16),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.02))] text-white hover:border-emerald-300/35'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`rounded-full px-3 py-1 text-[10px] tracking-[0.18em] ${
item.locked
? 'border border-white/8 bg-black/18 text-zinc-400'
: 'border border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
<span className="text-lg leading-none text-white/45">
{item.locked ? '·' : '→'}
</span>
</div>
<div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</button>
);
}
export function PlatformCreationTypeModal({
isOpen,
isBusy,
error,
onClose,
onSelectRpg,
}: PlatformCreationTypeModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div
className="pixel-nine-slice w-full max-w-3xl"
style={getNineSliceStyle(UI_CHROME.modalPanel, {
paddingX: 18,
paddingY: 18,
})}
>
<div className="rounded-[1.8rem] bg-[linear-gradient(180deg,rgba(11,16,22,0.98),rgba(8,10,14,0.98))]">
<div className="flex items-start justify-between gap-3 border-b border-white/8 px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-white">
</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="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-3">
{CREATION_GAME_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
}}
/>
))}
</div>
{error ? (
<div className="mt-4 rounded-[1.25rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,14 +1,27 @@
import { type ComponentType, useMemo } from 'react';
import {
BookOpen,
Camera,
ChevronRight,
Clock3,
Coins,
Copy,
Crown,
MessageCircle,
Pencil,
Settings,
Ticket,
UserPlus,
} from 'lucide-react';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import type { CustomWorldProfile } from '../../types';
import {
CHROME_ICONS,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import {
@@ -20,43 +33,20 @@ import {
resolvePlatformWorldLeadPortrait,
} from './platformWorldPresentation';
function SectionHeader({
title,
detail,
actionLabel,
onAction,
}: {
title: string;
detail: string;
actionLabel?: string;
onAction?: (() => void) | null;
}) {
export type PlatformHomeTab = 'home' | 'create' | 'profile';
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3 flex items-end justify-between gap-3">
<div>
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
{detail}
</div>
<div className="mt-1 text-base font-bold text-white">{title}</div>
<div className="mb-3">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
{detail}
</div>
{actionLabel && onAction ? (
<button
type="button"
onClick={onAction}
className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
>
{actionLabel}
</button>
) : null}
<div className="mt-1 text-base font-bold text-white">{title}</div>
</div>
);
}
function EmptyShelf({
text,
}: {
text: string;
}) {
function EmptyShelf({ text }: { text: string }) {
return (
<div
className="pixel-nine-slice pixel-panel rounded-[1.35rem] text-sm leading-6 text-zinc-300"
@@ -150,7 +140,173 @@ function WorldCard({
);
}
function PlatformTabButton({
active,
label,
iconSrc,
onClick,
}: {
active: boolean;
label: string;
iconSrc: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex h-full w-full items-center justify-center rounded-[1rem] px-2 py-1.5 transition ${
active
? 'border border-white/15 bg-white/8 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]'
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-100'
}`}
>
<span className="flex flex-col items-center justify-center gap-1">
<span
className={`flex h-6 w-6 items-center justify-center rounded-full ${
active
? 'bg-white/10 shadow-[0_0_14px_rgba(255,255,255,0.14)]'
: 'bg-black/10'
}`}
>
<PixelIcon
src={iconSrc}
className={`h-[1.125rem] w-[1.125rem] ${
active ? 'opacity-100' : 'opacity-65 grayscale'
}`}
/>
</span>
<span
className={`text-[11px] font-semibold tracking-[0.18em] ${
active ? 'text-white' : 'text-zinc-400'
}`}
>
{label}
</span>
</span>
</button>
);
}
function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function describeLoginMethod(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'phone':
return '手机号';
case 'wechat':
return '微信';
default:
return '账号密码';
}
}
function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
}
function formatPlayTime(playTimeMs: number) {
const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (days > 0) {
return `${days}${hours}小时`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}`;
}
return `${minutes}`;
}
function buildPublicUserCode(user: AuthUser | null | undefined) {
const raw =
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
user?.username.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
'00000000';
return `SY-${raw.slice(-8).padStart(8, '0')}`;
}
function getUserAvatarLabel(user: AuthUser | null | undefined) {
return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase();
}
function copyText(value: string) {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(value);
}
function ProfileStatCard({
label,
value,
icon,
}: {
label: string;
value: string;
icon: ComponentType<{ className?: string }>;
}) {
const Icon = icon;
return (
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
<div className="flex items-center gap-2 text-zinc-400">
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
</div>
<div className="mt-3 text-lg font-black text-white">{value}</div>
</div>
);
}
function ProfileShortcutButton({
label,
icon,
onClick,
}: {
label: string;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ?? undefined}
className="flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] border border-white/10 bg-black/16 px-3 py-3 text-center transition hover:border-white/20 hover:bg-white/6"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
<Icon className="h-[1.125rem] w-[1.125rem]" />
</div>
<div className="text-sm font-semibold text-white">{label}</div>
</button>
);
}
export function PlatformHomeView({
activeTab,
onTabChange,
hasSavedGame,
savedSnapshot,
featuredEntries,
@@ -159,11 +315,13 @@ export function PlatformHomeView({
isLoadingPlatform,
platformError,
onContinueGame,
onRefresh,
onOpenCreateWorld,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenLibraryDetail,
}: {
activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
featuredEntries: CustomWorldGalleryCard[];
@@ -172,12 +330,18 @@ export function PlatformHomeView({
isLoadingPlatform: boolean;
platformError: string | null;
onContinueGame: () => void;
onRefresh: () => void;
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
onOpenLibraryDetail: (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
}) {
const authUi = useAuthUi();
const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6),
[featuredEntries],
);
const snapshotWorldName =
savedSnapshot?.gameState.customWorldProfile?.name ??
savedSnapshot?.gameState.currentScenePreset?.name ??
@@ -186,169 +350,401 @@ export function PlatformHomeView({
savedSnapshot?.gameState.playerCharacter?.title ??
savedSnapshot?.gameState.playerCharacter?.name ??
'旅人';
const featuredShelf = featuredEntries.slice(0, 6);
const snapshotDigest =
savedSnapshot?.gameState.storyEngineMemory?.continueGameDigest ??
savedSnapshot?.currentStory?.text ??
savedSnapshot?.gameState.customWorldProfile?.summary ??
'上一次冒险已经保存,可以从这里继续推进故事。';
const publicUserCode = buildPublicUserCode(authUi?.user);
const avatarLabel = getUserAvatarLabel(authUi?.user);
const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0;
const totalPlayTime = formatPlayTime(
savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0,
);
const playedWorkCount = hasSavedGame ? 1 : 0;
const tabIcons = {
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
create: '/Icons/01_Scroll.png',
profile: '/UI/Icon_Eq_Head.png',
} as const;
const recentPlayItems = savedSnapshot
? [
{
id: 'latest-save',
title: snapshotWorldName,
subtitle: snapshotCharacterName,
summary: snapshotDigest,
updatedAt: savedSnapshot.savedAt,
},
]
: [];
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-300/20 bg-amber-500/10">
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
GENARRATIVE PLATFORM
let content = (
<div className="space-y-4 pb-2">
<button
type="button"
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
</span>
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
{hasSavedGame ? '继续冒险' : '创建世界'}
</div>
<div className="truncate text-lg font-black text-white">
广
</div>
<div>
<div className="text-3xl font-black text-white">
{hasSavedGame ? snapshotWorldName : '写下一个能被游玩的世界'}
</div>
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
{hasSavedGame
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
>
</button>
{authUi?.user ? (
<button
type="button"
onClick={() => authUi.openAccountModal()}
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-100 transition hover:border-white/20 hover:text-white"
</button>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{platformError}
</div>
) : null}
<section>
<SectionHeader title="精选推荐" detail="为你挑选" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
) : featuredShelf.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{featuredShelf.map((entry: CustomWorldGalleryCard) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
entry={entry}
badge="推荐"
metaLabel={describePlatformThemeLabel(entry.themeMode)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
)}
</section>
<section>
<SectionHeader title="最新发布" detail="玩家广场" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : latestEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: CustomWorldGalleryCard) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有新作品。" />
)}
</section>
</div>
);
if (activeTab === 'create') {
content = (
<div className="space-y-4 pb-2">
<button
type="button"
onClick={onOpenCreateTypePicker}
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_38%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.92))]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<span className="w-fit rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
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>
</div>
</button>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取你的作品..." />
) : myEntries.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{myEntries.map(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
entry={entry}
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
metaLabel={
entry.visibility === 'published'
? formatPlatformWorldTime(entry.publishedAt)
: '仅自己可见'
}
onClick={() => onOpenLibraryDetail(entry)}
/>
),
)}
</div>
) : (
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
)}
</section>
</div>
);
}
if (activeTab === 'profile') {
content = (
<div className="space-y-4 pb-2">
{authUi?.user ? (
<>
<section className="overflow-hidden rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(248,244,236,0.96),rgba(232,225,214,0.92))] p-4 text-slate-900 shadow-[0_18px_50px_rgba(0,0,0,0.18)]">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={() => authUi.openAccountModal()}
className="relative h-16 w-16 shrink-0 rounded-[1.4rem] bg-[linear-gradient(135deg,#2a3141,#66718a)] text-white shadow-[0_12px_24px_rgba(15,23,42,0.22)]"
>
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
{avatarLabel}
</span>
<span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-white/30 bg-white/85 text-slate-700">
<Camera className="h-3.5 w-3.5" />
</span>
</button>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-xl font-black text-slate-900">
{authUi.user.displayName}
</div>
<button
type="button"
onClick={() => authUi.openAccountModal()}
className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/6 text-slate-700 transition hover:bg-slate-900/10"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span> {publicUserCode}</span>
<button
type="button"
onClick={() => copyText(publicUserCode)}
className="flex items-center gap-1 rounded-full bg-slate-900/6 px-2 py-1 text-slate-700 transition hover:bg-slate-900/10"
>
<Copy className="h-3 w-3" />
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
{describeLoginMethod(authUi.user.loginMethod)}
</span>
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
{describeBindingStatus(authUi.user.bindingStatus)}
</span>
</div>
</div>
</div>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-[1.1rem] bg-[linear-gradient(135deg,#5d79ff,#8ba2ff)] px-3 py-2 text-left text-white shadow-[0_12px_24px_rgba(93,121,255,0.28)]"
>
<Crown className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] text-white/80"></div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
</div>
</section>
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
{authUi.user.displayName}
</button>
) : null}
<div className="grid grid-cols-3 gap-3">
<ProfileStatCard
label="剩余叙世币"
value={`${remainingNarrativeCoins}`}
icon={Coins}
/>
<ProfileStatCard
label="总游戏时长"
value={totalPlayTime}
icon={Clock3}
/>
<ProfileStatCard
label="玩过作品"
value={`${playedWorkCount}`}
icon={BookOpen}
/>
</div>
</section>
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<SectionHeader title="最近游玩" detail="继续上次进度" />
{recentPlayItems.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{recentPlayItems.map((item) => (
<button
key={item.id}
type="button"
onClick={onContinueGame}
className="relative flex h-[10.5rem] w-[17rem] shrink-0 overflow-hidden rounded-[1.4rem] border border-white/10 bg-[linear-gradient(135deg,rgba(25,32,46,0.95),rgba(9,12,18,0.96))] p-4 text-left"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(96,165,250,0.18),transparent_35%),radial-gradient(circle_at_bottom_left,rgba(250,204,21,0.12),transparent_28%)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
RECENT PLAY
</span>
<span className="text-[11px] text-zinc-400">
{formatSnapshotTime(item.updatedAt)}
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-white">
{item.title}
</div>
<div className="mt-1 text-sm text-zinc-300">
{item.subtitle}
</div>
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
{item.summary}
</div>
</div>
</div>
</button>
))}
</div>
) : (
<EmptyShelf text="还没有最近游玩的存档,去首页挑一个世界开始冒险吧。" />
)}
</section>
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<SectionHeader title="常用功能" detail="快捷入口" />
<div className="grid grid-cols-3 gap-3">
<ProfileShortcutButton label="邀请好友" icon={UserPlus} />
<ProfileShortcutButton label="填邀请码" icon={Ticket} />
<ProfileShortcutButton label="玩家社区" icon={MessageCircle} />
</div>
</section>
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<button
type="button"
onClick={() => authUi.openAccountModal()}
className="flex w-full items-center justify-between gap-3 rounded-[1.25rem] border border-white/10 bg-black/16 px-4 py-4 text-left transition hover:border-white/20 hover:bg-white/6"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
<Settings className="h-[1.125rem] w-[1.125rem]" />
</div>
<div>
<div className="text-base font-semibold text-white"></div>
<div className="text-xs text-zinc-400"></div>
</div>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</section>
</>
) : (
<EmptyShelf text="当前还没有读取到账户信息。" />
)}
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4">
<div className="text-lg font-black text-white"></div>
<div className="mt-1 text-[10px] tracking-[0.28em] text-zinc-500">
GENARRATIVE
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
<div className="space-y-4 pb-2">
<button
type="button"
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
</span>
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
{hasSavedGame ? '继续冒险' : '创建世界'}
</div>
</div>
<div>
<div className="text-3xl font-black text-white">
{hasSavedGame ? snapshotWorldName : '把第一页变成你的作品页'}
</div>
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
{hasSavedGame
? `${snapshotCharacterName} 的上一次冒险已保存在云端,点这里直接回到故事现场。`
: '从设定、角色到场景网络,一次生成一部可游玩的自定义 RPG再决定是否发布到广场。'}
</div>
</div>
</div>
</button>
{content}
</div>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{platformError}
</div>
) : null}
<section>
<SectionHeader
title="精选推荐"
detail="为你挑选"
actionLabel="看看最新"
onAction={onRefresh}
/>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
) : featuredShelf.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{featuredShelf.map((entry) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
entry={entry}
badge="推荐"
metaLabel={describePlatformThemeLabel(entry.themeMode)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
)}
</section>
<section>
<SectionHeader title="最新发布" detail="玩家广场" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : latestEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有新作品。" />
)}
</section>
<section>
<SectionHeader title="我的作品" detail="草稿与已发布" />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<button
type="button"
onClick={onOpenCreateWorld}
className="pixel-nine-slice pixel-pressable relative min-h-[13rem] overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.92))]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
</div>
<div className="mt-auto">
<div className="text-2xl font-black text-white">
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
稿
</div>
</div>
</div>
</button>
{myEntries.map((entry) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
entry={entry}
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
metaLabel={entry.visibility === 'published' ? formatPlatformWorldTime(entry.publishedAt) : '仅自己可见'}
onClick={() => onOpenLibraryDetail(entry)}
/>
))}
</div>
{!isLoadingPlatform && myEntries.length === 0 ? (
<div className="mt-3">
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
</div>
) : null}
</section>
<div
className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
>
<div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
iconSrc={tabIcons.home}
onClick={() => onTabChange('home')}
/>
<PlatformTabButton
active={activeTab === 'create'}
label="创作"
iconSrc={tabIcons.create}
onClick={() => onTabChange('create')}
/>
<PlatformTabButton
active={activeTab === 'profile'}
label="我的"
iconSrc={tabIcons.profile}
onClick={() => onTabChange('profile')}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,153 @@
/* @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,
getCustomWorldAgentSession,
} from '../../services/aiService';
import {
listCustomWorldGallery,
listCustomWorldLibrary,
} from '../../services/storageService';
import type { GameState } from '../../types';
import {
PreGameSelectionFlow,
type SelectionStage,
} from './PreGameSelectionFlow';
vi.mock('../../services/aiService', () => ({
createCustomWorldAgentSession: vi.fn(),
executeCustomWorldAgentAction: vi.fn(),
generateCustomWorldProfile: vi.fn(),
getCustomWorldAgentOperation: vi.fn(),
getCustomWorldAgentSession: vi.fn(),
sendCustomWorldAgentMessage: vi.fn(),
}));
vi.mock('../../services/storageService', () => ({
getCustomWorldGalleryDetail: vi.fn(),
listCustomWorldGallery: vi.fn(),
listCustomWorldLibrary: vi.fn(),
publishCustomWorldProfile: vi.fn(),
unpublishCustomWorldProfile: vi.fn(),
upsertCustomWorldProfile: vi.fn(),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
}: {
session: CustomWorldAgentSessionSnapshot | null;
}) => (
<div className="agent-workspace-mock">
Agent工作区{session?.sessionId ?? 'missing-session'}
</div>
),
}));
const mockSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
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',
};
function TestWrapper() {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
return (
<PreGameSelectionFlow
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={() => {}}
/>
);
}
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(getCustomWorldAgentSession).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 />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //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();
});

View File

@@ -10,6 +10,12 @@ import {
} from 'react';
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGalleryCard,
CustomWorldGenerationProgress,
@@ -17,7 +23,24 @@ import type {
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { generateCustomWorldProfile } from '../../services/aiService';
import {
createCustomWorldAgentSession,
executeCustomWorldAgentAction,
generateCustomWorldProfile,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
sendCustomWorldAgentMessage,
} from '../../services/aiService';
import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
isDraftFoundationOperationRunning,
} from '../../services/customWorldAgentGenerationProgress';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
@@ -37,7 +60,8 @@ import {
type CustomWorldProfile,
type GameState,
} from '../../types';
import { PlatformHomeView } from './PlatformHomeView';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView';
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
const CustomWorldGenerationView = lazy(async () => {
@@ -61,12 +85,27 @@ const CustomWorldCreatorModal = lazy(async () => {
};
});
const CustomWorldAgentWorkspace = lazy(async () => {
const module = await import(
'../custom-world-agent/CustomWorldAgentWorkspace'
);
return {
default: module.CustomWorldAgentWorkspace,
};
});
export type SelectionStage =
| 'platform'
| 'detail'
| 'agent-workspace'
| 'custom-world-generating'
| 'custom-world-result';
type CustomWorldGenerationViewSource =
| 'classic'
| 'agent-draft-foundation'
| null;
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
@@ -151,6 +190,22 @@ function resolveErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
function createFailedAgentOperation(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,
};
}
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
@@ -170,6 +225,8 @@ export function PreGameSelectionFlow({
handleStartNewGame,
handleCustomWorldSelect,
}: PreGameSelectionFlowProps) {
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
@@ -178,8 +235,25 @@ export function PreGameSelectionFlow({
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
CustomWorldGalleryCard[]
>([]);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(
null,
);
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 [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState<CustomWorldCreatorIntent>(() =>
@@ -196,6 +270,10 @@ export function PreGameSelectionFlow({
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldProgress, setCustomWorldProgress] =
useState<CustomWorldGenerationProgress | null>(null);
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
useState<CustomWorldGenerationViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
const previewCustomWorldCharacters = useMemo(
@@ -211,6 +289,24 @@ export function PreGameSelectionFlow({
[publishedGalleryEntries],
);
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 getCustomWorldAgentSession(sessionId);
setAgentSession(nextSession);
return nextSession;
}, []);
const refreshPlatformData = useCallback(async () => {
setIsLoadingPlatform(true);
setPlatformError(null);
@@ -239,6 +335,18 @@ export function PreGameSelectionFlow({
}
}, [selectedDetailEntry]);
useEffect(() => {
if (hasAppliedInitialAgentWorkspaceRef.current) {
return;
}
hasAppliedInitialAgentWorkspaceRef.current = true;
if (initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab('create');
setSelectionStage('agent-workspace');
}
}, [setSelectionStage]);
useEffect(() => {
let isActive = true;
@@ -293,6 +401,117 @@ export function PreGameSelectionFlow({
[],
);
useEffect(() => {
if (!activeAgentSessionId) {
setAgentSession(null);
setIsLoadingAgentSession(false);
return;
}
let cancelled = false;
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
.then(() => {
if (!cancelled) {
setCreationTypeError(null);
}
})
.catch((error) => {
if (cancelled) {
return;
}
setCreationTypeError(
resolveErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
setAgentSession(null);
setAgentOperation(null);
persistAgentUiState(null, null);
setPlatformTab('create');
setSelectionStage('platform');
})
.finally(() => {
if (!cancelled) {
setIsLoadingAgentSession(false);
}
});
return () => {
cancelled = true;
};
}, [
activeAgentSessionId,
persistAgentUiState,
setSelectionStage,
syncAgentSessionSnapshot,
]);
useEffect(() => {
if (!activeAgentSessionId || !activeAgentOperationId) {
return;
}
let cancelled = false;
const pollOperation = async () => {
try {
const nextOperation = await getCustomWorldAgentOperation(
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;
}
const errorMessage = resolveErrorMessage(
error,
'读取共创操作状态失败。',
);
setAgentOperation(
createFailedAgentOperation({
type: 'process_message',
phaseLabel: '读取操作状态失败',
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
};
void pollOperation();
const intervalId = window.setInterval(() => {
void pollOperation();
}, 1200);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [
activeAgentOperationId,
activeAgentSessionId,
persistAgentUiState,
syncAgentSessionSnapshot,
]);
const customWorldSettingPreview = useMemo(() => {
if (customWorldCreatorIntent.sourceMode === 'freeform') {
return customWorldCreatorIntent.rawSettingText.trim();
@@ -308,10 +527,40 @@ export function PreGameSelectionFlow({
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
const agentDraftSettingPreview = useMemo(
() => buildAgentDraftFoundationSettingText(agentSession),
[agentSession],
);
const agentDraftGenerationProgress = useMemo(
() =>
buildAgentDraftFoundationGenerationProgress(
agentOperation,
agentDraftGenerationStartedAt,
),
[agentDraftGenerationStartedAt, agentOperation],
);
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const activeGenerationProgress = isAgentDraftGenerationView
? agentDraftGenerationProgress
: customWorldProgress;
const isActiveGenerationRunning = isAgentDraftGenerationView
? isDraftFoundationOperationRunning(agentOperation)
: isGeneratingCustomWorld;
const activeGenerationError =
isAgentDraftGenerationView &&
isDraftFoundationOperation(agentOperation) &&
agentOperation.status === 'failed'
? agentOperation.error || agentOperation.phaseDetail
: customWorldError;
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
};
@@ -322,6 +571,101 @@ export function PreGameSelectionFlow({
setCustomWorldError(null);
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('platform');
};
const openCreationTypePicker = () => {
if (isCreatingAgentSession) {
return;
}
setCreationTypeError(null);
setShowCreationTypeModal(true);
};
const openRpgAgentWorkspace = async () => {
if (isCreatingAgentSession) {
return;
}
setIsCreatingAgentSession(true);
setCreationTypeError(null);
try {
const { session } = await createCustomWorldAgentSession({});
setAgentSession(session);
setAgentOperation(null);
persistAgentUiState(session.sessionId, null);
setShowCreationTypeModal(false);
setPlatformTab('create');
setSelectionStage('agent-workspace');
} catch (error) {
setCreationTypeError(resolveErrorMessage(error, '开启共创工作台失败。'));
} finally {
setIsCreatingAgentSession(false);
}
};
const submitAgentMessage = async (
payload: SendCustomWorldAgentMessageRequest,
) => {
if (!activeAgentSessionId) {
return;
}
try {
const { operation } = await sendCustomWorldAgentMessage(
activeAgentSessionId,
payload,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
} catch (error) {
const errorMessage = resolveErrorMessage(error, '发送共创消息失败。');
setAgentOperation(
createFailedAgentOperation({
type: 'process_message',
phaseLabel: '发送消息失败',
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
};
const executeAgentAction = async (payload: CustomWorldAgentActionRequest) => {
if (!activeAgentSessionId) {
return;
}
try {
const { operation } = await executeCustomWorldAgentAction(
activeAgentSessionId,
payload,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
} catch (error) {
const errorMessage = resolveErrorMessage(error, '执行共创操作失败。');
setAgentOperation(
createFailedAgentOperation({
type:
payload.action === 'draft_foundation'
? 'draft_foundation'
: payload.action,
phaseLabel: '执行操作失败',
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
};
const leaveAgentWorkspace = () => {
setPlatformTab('create');
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setSelectionStage('platform');
};
@@ -340,7 +684,9 @@ export function PreGameSelectionFlow({
setDetailError(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
setCustomWorldCreatorIntent(createEmptyCustomWorldCreatorIntent('freeform'));
setCustomWorldCreatorIntent(
createEmptyCustomWorldCreatorIntent('freeform'),
);
setCustomWorldGenerationMode('fast');
setShowCustomWorldModal(true);
};
@@ -400,7 +746,9 @@ export function PreGameSelectionFlow({
}
try {
const mutation = await upsertCustomWorldProfile(generatedCustomWorldProfile);
const mutation = await upsertCustomWorldProfile(
generatedCustomWorldProfile,
);
setSavedCustomWorldEntries(mutation.entries);
setSelectedDetailEntry(mutation.entry);
await refreshPlatformData();
@@ -684,18 +1032,20 @@ export function PreGameSelectionFlow({
className="flex h-full min-h-0 flex-col"
>
<PlatformHomeView
activeTab={platformTab}
onTabChange={setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
featuredEntries={featuredGalleryEntries}
latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries}
isLoadingPlatform={isLoadingPlatform}
platformError={isLoadingPlatform ? null : platformError}
platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError)
}
onContinueGame={handleContinueGame}
onRefresh={() => {
void refreshPlatformData();
}}
onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
void openGalleryDetail(entry);
}}
@@ -750,6 +1100,50 @@ export function PreGameSelectionFlow({
</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 共创工作区..." />
}
>
{agentSession ? (
<CustomWorldAgentWorkspace
session={agentSession}
activeOperation={agentOperation}
onBack={leaveAgentWorkspace}
onRefresh={() => {
if (!activeAgentSessionId) {
return;
}
void syncAgentSessionSnapshot(activeAgentSessionId);
}}
onSubmitMessage={(payload) => {
void submitAgentMessage(payload);
}}
onExecuteAction={(payload) => {
void executeAgentAction(payload);
}}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
{isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: creationTypeError || '正在恢复创作工作区...'}
</div>
</div>
)}
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-generating' && (
<motion.div
key="custom-world-generating"
@@ -759,9 +1153,7 @@ export function PreGameSelectionFlow({
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={
<LazyPanelFallback label="正在加载世界生成面板..." />
}
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
>
<CustomWorldGenerationView
settingText={customWorldSettingPreview}
@@ -816,6 +1208,21 @@ export function PreGameSelectionFlow({
)}
</AnimatePresence>
<PlatformCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={isCreatingAgentSession}
error={creationTypeError}
onClose={() => {
if (isCreatingAgentSession) {
return;
}
setShowCreationTypeModal(false);
}}
onSelectRpg={() => {
void openRpgAgentWorkspace();
}}
/>
{showCustomWorldModal ? (
<Suspense fallback={null}>
<CustomWorldCreatorModal

View File

@@ -151,9 +151,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
Boolean(gameState.worldType) &&
!gameState.playerCharacter;
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
shellViewModel.selectionStage !== 'platform';
const hideSelectionHero = gameState.currentScene === 'Selection';
const dialogueIndicator = useMemo(
() =>

View File

@@ -0,0 +1,131 @@
import { expect, test } from 'vitest';
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/customWorldAgent';
import {
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperationRunning,
} from './customWorldAgentGenerationProgress';
const baseOperation: CustomWorldAgentOperationRecord = {
operationId: 'operation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
progress: 38,
error: null,
};
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
stage: 'foundation_review',
focusCardId: null,
creatorIntent: {
sourceMode: 'card',
worldHook: '海雾、旧灯塔和失控航路交织的边缘群岛',
themeKeywords: ['海雾', '灯塔', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
playerPremise: '玩家刚回到群岛,准备调查父亲沉船的真相。',
openingSituation: '首夜就有陌生船只在禁航区点灯。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: null,
lockState: null,
draftProfile: null,
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个被海雾吞没的旧航路世界。',
createdAt: '2026-04-14T10:00:00.000Z',
relatedOperationId: null,
},
],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-14T10:00:00.000Z',
};
test('maps running draft_foundation operation to legacy generation progress', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
baseOperation,
1_000,
5_000,
);
expect(progress).not.toBeNull();
expect(progress?.phaseId).toBe('foundation');
expect(progress?.batchLabel).toBe('生成世界底稿');
expect(progress?.overallProgress).toBe(38);
expect(progress?.elapsedMs).toBe(4_000);
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
expect(progress?.steps.map((step) => step.status)).toEqual([
'completed',
'active',
'pending',
'pending',
]);
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
});
test('marks all legacy progress steps complete when draft foundation finishes', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 6 张草稿卡已经整理完成。',
progress: 100,
},
1_000,
5_000,
);
expect(progress?.phaseId).toBe('workspace');
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
true,
);
});
test('builds readable draft setting text from creator intent first', () => {
const settingText = buildAgentDraftFoundationSettingText(baseSession);
expect(settingText).toContain('世界核心');
expect(settingText).toContain('玩家开局');
expect(settingText).toContain('标志元素');
});
test('falls back to latest user message when creator intent is unavailable', () => {
const settingText = buildAgentDraftFoundationSettingText({
...baseSession,
creatorIntent: null,
});
expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。');
});

View File

@@ -0,0 +1,210 @@
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{
id: 'queue',
label: '接收生成请求',
detail: '正在锁定当前已确认的世界锚点与草稿范围。',
},
{
id: 'foundation',
label: '生成世界底稿',
detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。',
},
{
id: 'cards',
label: '编译草稿卡',
detail: '正在整理世界卡、角色卡与地点卡的摘要和详情。',
},
{
id: 'workspace',
label: '准备精修工作区',
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
},
] as const satisfies ReadonlyArray<{
id: string;
label: string;
detail: string;
}>;
function clampProgress(progress: number | null | undefined) {
if (typeof progress !== 'number' || Number.isNaN(progress)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(progress)));
}
function resolveAgentDraftFoundationStepIndex(
operation: CustomWorldAgentOperationRecord,
) {
const progress = clampProgress(operation.progress);
const phaseLabel = operation.phaseLabel.trim();
if (
operation.status === 'completed' ||
phaseLabel.includes('世界底稿已生成') ||
progress >= 90
) {
return 3;
}
if (phaseLabel.includes('编译草稿卡') || progress >= 60) {
return 2;
}
if (phaseLabel.includes('生成世界底稿') || progress >= 25) {
return 1;
}
return 0;
}
function buildAgentDraftFoundationSteps(
operation: CustomWorldAgentOperationRecord,
activeStepIndex: number,
) {
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
const isCompleted =
operation.status === 'completed' || index < activeStepIndex;
const isActive = !isCompleted && index === activeStepIndex;
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : 0,
total: 1,
status: isCompleted
? 'completed'
: isActive
? 'active'
: 'pending',
} satisfies CustomWorldGenerationStep;
});
}
function resolveEstimatedRemainingMs(
progress: number,
startedAtMs: number | null,
nowMs: number,
status: CustomWorldAgentOperationRecord['status'],
) {
if (status === 'completed') {
return 0;
}
if (!startedAtMs || progress <= 0 || progress >= 100) {
return null;
}
const elapsedMs = Math.max(0, nowMs - startedAtMs);
const progressFraction = progress / 100;
return Math.max(
0,
Math.round(elapsedMs / progressFraction - elapsedMs),
);
}
export function isDraftFoundationOperation(
operation: CustomWorldAgentOperationRecord | null | undefined,
): operation is CustomWorldAgentOperationRecord {
return Boolean(operation && operation.type === 'draft_foundation');
}
export function isDraftFoundationOperationRunning(
operation: CustomWorldAgentOperationRecord | null | undefined,
) {
return (
isDraftFoundationOperation(operation) &&
(operation.status === 'queued' || operation.status === 'running')
);
}
export function buildAgentDraftFoundationGenerationProgress(
operation: CustomWorldAgentOperationRecord | null | undefined,
startedAtMs: number | null,
nowMs = Date.now(),
): CustomWorldGenerationProgress | null {
if (!isDraftFoundationOperation(operation)) {
return null;
}
const overallProgress = clampProgress(operation.progress);
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const estimatedRemainingMs = resolveEstimatedRemainingMs(
overallProgress,
startedAtMs,
nowMs,
operation.status,
);
const activeStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
return {
phaseId: activeStep.id,
phaseLabel: operation.phaseLabel || activeStep.label,
phaseDetail: operation.phaseDetail || activeStep.detail,
batchLabel: activeStep.label,
overallProgress,
completedWeight: overallProgress,
totalWeight: 100,
elapsedMs,
estimatedRemainingMs,
activeStepIndex,
steps: buildAgentDraftFoundationSteps(operation, activeStepIndex),
};
}
export function buildAgentDraftFoundationSettingText(
session: CustomWorldAgentSessionSnapshot | null | undefined,
) {
if (!session) {
return '';
}
const creatorIntent = normalizeCustomWorldCreatorIntent(
session.creatorIntent,
'freeform',
);
if (creatorIntent) {
const displayText =
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
const generationText =
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
if (displayText) {
return displayText;
}
if (generationText) {
return generationText;
}
if (creatorIntent.rawSettingText.trim()) {
return creatorIntent.rawSettingText.trim();
}
}
const latestUserMessage = [...session.messages]
.reverse()
.find((message) => message.role === 'user' && message.text.trim());
return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。';
}