Files
Genarrative/src/components/game-shell/PreGameSelectionFlow.tsx

750 lines
26 KiB
TypeScript

import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
createCustomWorldAgentSession,
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
listCustomWorldWorks,
sendCustomWorldAgentMessage,
} from '../../services/aiService';
import {
clearCustomWorldAgentUiState,
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import { detectCustomWorldThemeMode } from '../../services/customWorldTheme';
import { listCustomWorldLibrary } from '../../services/storageService';
import { type CustomWorldProfile, type GameState } from '../../types';
import {
CHROME_ICONS,
CUSTOM_WORLD_THEME_ICONS,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import { CustomWorldAgentWorkspace } from '../custom-world-agent/CustomWorldAgentWorkspace';
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { DeveloperTeamModal } from '../DeveloperTeamModal';
import { PixelIcon } from '../PixelIcon';
export type SelectionStage =
| 'start'
| 'world'
| 'custom-world-home'
| 'custom-world-agent';
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
const DEVELOPER_TEAM_MESSAGE =
'\u7a0b\u7b56\u7f8e\uff1a\u53d9\u4e16AI \u5305\u4ef2\u822a\n\u5408\u4f5c\u8bf7\u8054\u7cfb\u5fae\u4fe1\uff1abzh253518756';
const START_SCREEN_CONTACTS = [
{ label: 'QQ群', value: '1094580241' },
{ label: '微信', value: 'bzh253518756' },
] as const;
function createOperationErrorBanner(
message: string,
): CustomWorldAgentOperationRecord {
return {
operationId: `operation-error-${Date.now()}`,
type: 'process_message',
status: 'failed',
phaseLabel: '处理失败',
phaseDetail: message,
progress: 100,
error: message,
};
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
gameState,
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
}: PreGameSelectionFlowProps) {
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
CustomWorldProfile[]
>([]);
const [customWorldWorks, setCustomWorldWorks] = useState<
CustomWorldWorkSummary[]
>([]);
const [isLoadingCustomWorldWorks, setIsLoadingCustomWorldWorks] =
useState(false);
const [customWorldWorksError, setCustomWorldWorksError] =
useState<string | null>(null);
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [isCreatingCustomWorldWork, setIsCreatingCustomWorldWork] =
useState(false);
const [isRestoringAgentSession, setIsRestoringAgentSession] = useState(false);
const [activeCustomWorldAgentSessionId, setActiveCustomWorldAgentSessionId] =
useState<string | null>(null);
const [
activeCustomWorldAgentOperationId,
setActiveCustomWorldAgentOperationId,
] = useState<string | null>(null);
const [activeCustomWorldAgentSession, setActiveCustomWorldAgentSession] =
useState<CustomWorldAgentSessionSnapshot | null>(null);
const [activeCustomWorldAgentOperation, setActiveCustomWorldAgentOperation] =
useState<CustomWorldAgentOperationRecord | null>(null);
const clearOperationTimeoutRef = useRef<number | null>(null);
const savedCustomWorldCards = useMemo(
() =>
savedCustomWorldProfiles.map((profile) => {
const themeMode = detectCustomWorldThemeMode(profile);
const leadCharacter =
buildCustomWorldPlayableCharacters(profile)[0] ?? null;
return {
id: profile.id,
profile,
texture: UI_CHROME.panel,
sceneImage: resolveCustomWorldCampSceneImage(profile) ?? '',
featurePortrait: leadCharacter?.portrait ?? '',
featureIcon:
themeMode === 'martial'
? CUSTOM_WORLD_THEME_ICONS.martial
: themeMode === 'arcane'
? CUSTOM_WORLD_THEME_ICONS.arcane
: CHROME_ICONS.refreshOptions,
accentLabel: '自定义世界',
};
}),
[savedCustomWorldProfiles],
);
const refreshCustomWorldLibrary = useCallback(async () => {
try {
setSavedCustomWorldProfiles(await listCustomWorldLibrary());
} catch (error) {
console.warn(
'[PreGameSelectionFlow] failed to load custom world library',
error,
);
}
}, []);
const refreshCustomWorldWorks = useCallback(async () => {
setIsLoadingCustomWorldWorks(true);
try {
setCustomWorldWorks(await listCustomWorldWorks());
setCustomWorldWorksError(null);
} catch (error) {
setCustomWorldWorksError(
error instanceof Error ? error.message : '读取创作作品失败。',
);
} finally {
setIsLoadingCustomWorldWorks(false);
}
}, []);
const refreshCustomWorldHomeData = useCallback(async () => {
await Promise.all([refreshCustomWorldLibrary(), refreshCustomWorldWorks()]);
}, [refreshCustomWorldLibrary, refreshCustomWorldWorks]);
const syncCustomWorldAgentUiState = useCallback(
(sessionId: string | null, operationId: string | null) => {
setActiveCustomWorldAgentSessionId(sessionId);
setActiveCustomWorldAgentOperationId(operationId);
writeCustomWorldAgentUiState({
activeSessionId: sessionId,
activeOperationId: operationId,
});
},
[],
);
const scheduleClearOperationBanner = useCallback(() => {
if (clearOperationTimeoutRef.current) {
window.clearTimeout(clearOperationTimeoutRef.current);
}
clearOperationTimeoutRef.current = window.setTimeout(() => {
setActiveCustomWorldAgentOperation(null);
setActiveCustomWorldAgentOperationId(null);
writeCustomWorldAgentUiState({
activeSessionId: activeCustomWorldAgentSessionId,
activeOperationId: null,
});
clearOperationTimeoutRef.current = null;
}, 1400);
}, [activeCustomWorldAgentSessionId]);
const refreshActiveCustomWorldAgentSession = useCallback(
async (sessionId: string) => {
const session = await getCustomWorldAgentSession(sessionId);
setActiveCustomWorldAgentSession(session);
return session;
},
[],
);
const loadCustomWorldAgentSession = useCallback(
async (sessionId: string, operationId?: string | null) => {
setIsRestoringAgentSession(true);
setActiveCustomWorldAgentSession(null);
try {
const session = await getCustomWorldAgentSession(sessionId);
setActiveCustomWorldAgentSession(session);
setActiveCustomWorldAgentOperation(null);
syncCustomWorldAgentUiState(sessionId, operationId ?? null);
setSelectionStage('custom-world-agent');
if (operationId) {
try {
const operation = await getCustomWorldAgentOperation(
sessionId,
operationId,
);
setActiveCustomWorldAgentOperation(operation);
} catch (error) {
setActiveCustomWorldAgentOperation(
createOperationErrorBanner(
error instanceof Error ? error.message : '读取操作状态失败。',
),
);
}
}
} catch (error) {
clearCustomWorldAgentUiState();
syncCustomWorldAgentUiState(null, null);
setSelectionStage('custom-world-home');
setCustomWorldWorksError(
error instanceof Error ? error.message : '恢复共创会话失败。',
);
} finally {
setIsRestoringAgentSession(false);
}
},
[setSelectionStage, syncCustomWorldAgentUiState],
);
const leaveCustomWorldAgentWorkspace = useCallback(async () => {
clearCustomWorldAgentUiState();
syncCustomWorldAgentUiState(null, null);
setActiveCustomWorldAgentSession(null);
setActiveCustomWorldAgentOperation(null);
setSelectionStage('custom-world-home');
await refreshCustomWorldHomeData();
}, [
refreshCustomWorldHomeData,
setSelectionStage,
syncCustomWorldAgentUiState,
]);
useEffect(() => {
void refreshCustomWorldHomeData();
const restoredState = readCustomWorldAgentUiState();
if (!gameState.worldType && restoredState.activeSessionId) {
void loadCustomWorldAgentSession(
restoredState.activeSessionId,
restoredState.activeOperationId ?? null,
);
}
}, [
gameState.worldType,
loadCustomWorldAgentSession,
refreshCustomWorldHomeData,
]);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'custom-world-home') {
void refreshCustomWorldHomeData();
}
}, [gameState.worldType, refreshCustomWorldHomeData, selectionStage]);
useEffect(() => {
if (
!activeCustomWorldAgentSessionId ||
!activeCustomWorldAgentOperationId ||
!activeCustomWorldAgentOperation ||
(activeCustomWorldAgentOperation.status !== 'queued' &&
activeCustomWorldAgentOperation.status !== 'running')
) {
return;
}
const timeoutId = window.setTimeout(async () => {
try {
const operation = await getCustomWorldAgentOperation(
activeCustomWorldAgentSessionId,
activeCustomWorldAgentOperationId,
);
setActiveCustomWorldAgentOperation(operation);
if (
operation.status === 'completed' ||
operation.status === 'failed'
) {
await refreshActiveCustomWorldAgentSession(
activeCustomWorldAgentSessionId,
);
await refreshCustomWorldWorks();
if (operation.status === 'completed') {
scheduleClearOperationBanner();
} else {
setActiveCustomWorldAgentOperationId(null);
writeCustomWorldAgentUiState({
activeSessionId: activeCustomWorldAgentSessionId,
activeOperationId: null,
});
}
}
} catch (error) {
setActiveCustomWorldAgentOperation(
createOperationErrorBanner(
error instanceof Error ? error.message : '读取操作状态失败。',
),
);
}
}, 500);
return () => window.clearTimeout(timeoutId);
}, [
activeCustomWorldAgentOperation,
activeCustomWorldAgentOperationId,
activeCustomWorldAgentSessionId,
refreshActiveCustomWorldAgentSession,
refreshCustomWorldWorks,
scheduleClearOperationBanner,
]);
useEffect(
() => () => {
if (clearOperationTimeoutRef.current) {
window.clearTimeout(clearOperationTimeoutRef.current);
}
},
[],
);
const openCustomWorldCreator = () => {
setSelectionStage('custom-world-home');
};
const startNewGame = () => {
handleStartNewGame();
clearCustomWorldAgentUiState();
syncCustomWorldAgentUiState(null, null);
setActiveCustomWorldAgentSession(null);
setActiveCustomWorldAgentOperation(null);
setSelectionStage('world');
};
const handleCreateNewWork = async () => {
if (isCreatingCustomWorldWork) {
return;
}
setIsCreatingCustomWorldWork(true);
setCustomWorldWorksError(null);
try {
const response = await createCustomWorldAgentSession({});
setActiveCustomWorldAgentSession(response.session);
setActiveCustomWorldAgentOperation(null);
syncCustomWorldAgentUiState(response.session.sessionId, null);
setSelectionStage('custom-world-agent');
await refreshCustomWorldWorks();
} catch (error) {
setCustomWorldWorksError(
error instanceof Error ? error.message : '创建共创会话失败。',
);
} finally {
setIsCreatingCustomWorldWork(false);
}
};
const handleResumeDraft = async (sessionId: string) => {
await loadCustomWorldAgentSession(sessionId, null);
};
const handleEnterPublished = async (profileId: string) => {
let profile =
savedCustomWorldProfiles.find((item) => item.id === profileId) ?? null;
if (!profile) {
const profiles = await listCustomWorldLibrary();
setSavedCustomWorldProfiles(profiles);
profile = profiles.find((item) => item.id === profileId) ?? null;
}
if (!profile) {
setCustomWorldWorksError('读取已发布世界失败。');
return;
}
handleCustomWorldSelect(profile);
};
const handleSubmitCustomWorldAgentMessage = async (
payload: SendCustomWorldAgentMessageRequest,
) => {
if (!activeCustomWorldAgentSessionId) {
return;
}
try {
const response = await sendCustomWorldAgentMessage(
activeCustomWorldAgentSessionId,
payload,
);
setActiveCustomWorldAgentOperation(response.operation);
syncCustomWorldAgentUiState(
activeCustomWorldAgentSessionId,
response.operation.operationId,
);
await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId);
} catch (error) {
setActiveCustomWorldAgentOperation(
createOperationErrorBanner(
error instanceof Error ? error.message : '发送共创消息失败。',
),
);
}
};
const handleExecuteCustomWorldAgentAction = async (
payload: CustomWorldAgentActionRequest,
) => {
if (!activeCustomWorldAgentSessionId) {
return;
}
try {
const response = await executeCustomWorldAgentAction(
activeCustomWorldAgentSessionId,
payload,
);
setActiveCustomWorldAgentOperation(response.operation);
syncCustomWorldAgentUiState(
activeCustomWorldAgentSessionId,
response.operation.operationId,
);
await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId);
} catch (error) {
setActiveCustomWorldAgentOperation(
createOperationErrorBanner(
error instanceof Error ? error.message : '执行共创动作失败。',
),
);
}
};
const handleRefreshCustomWorldAgentSession = async () => {
if (!activeCustomWorldAgentSessionId) {
return;
}
try {
await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId);
if (activeCustomWorldAgentOperationId) {
const operation = await getCustomWorldAgentOperation(
activeCustomWorldAgentSessionId,
activeCustomWorldAgentOperationId,
);
setActiveCustomWorldAgentOperation(operation);
}
} catch (error) {
setActiveCustomWorldAgentOperation(
createOperationErrorBanner(
error instanceof Error ? error.message : '刷新会话失败。',
),
);
}
};
return (
<>
<AnimatePresence mode="wait">
{!gameState.worldType && selectionStage === 'start' && (
<motion.div
key="start-screen"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full w-full items-center justify-center"
>
<div className="flex h-full w-full max-w-sm flex-col gap-5 py-4 sm:py-6">
<div className="flex min-h-0 flex-1 items-center">
<div className="flex w-full flex-col gap-3">
{hasSavedGame ? (
<button
type="button"
onClick={handleContinueGame}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
) : null}
<button
type="button"
onClick={startNewGame}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
{hasSavedGame ? '新游戏' : '开始游戏'}
</span>
<span className="text-white/60"></span>
</div>
</button>
<button
type="button"
onClick={() => setShowDeveloperTeamModal(true)}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel w-full"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 12,
paddingY: 10,
})}
>
<div className="text-[10px] font-bold tracking-[0.2em] text-emerald-200/75">
</div>
<div className="mt-3 space-y-2">
{START_SCREEN_CONTACTS.map((contact) => (
<div
key={contact.label}
className="flex items-center justify-between gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-sm text-zinc-200"
>
<span className="text-zinc-400">{contact.label}</span>
<span className="font-semibold text-white">
{contact.value}
</span>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
{!gameState.worldType && selectionStage === 'world' && (
<motion.div
key="world-select"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
</div>
<button
type="button"
onClick={() => setSelectionStage('start')}
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"
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
<button
type="button"
onClick={openCustomWorldCreator}
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between 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(125,211,252,0.18),transparent_42%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 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 opacity-95"
/>
</div>
<div className="relative z-10 flex h-full w-full flex-col">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="mt-auto">
<div className="text-3xl font-black text-white">
</div>
</div>
</div>
</button>
{savedCustomWorldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() => handleCustomWorldSelect(world.profile)}
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage ? (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-emerald-100">
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel}
</div>
</div>
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
</button>
))}
</div>
</div>
</motion.div>
)}
{!gameState.worldType && selectionStage === 'custom-world-home' && (
<motion.div
key="custom-world-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"
>
<CustomWorldCreationHub
items={customWorldWorks}
loading={isLoadingCustomWorldWorks}
error={customWorldWorksError}
onBack={() => setSelectionStage('world')}
onRetry={() => {
void refreshCustomWorldHomeData();
}}
onCreateNew={() => {
void handleCreateNewWork();
}}
onResumeDraft={(sessionId) => {
void handleResumeDraft(sessionId);
}}
onEnterPublished={(profileId) => {
void handleEnterPublished(profileId);
}}
/>
</motion.div>
)}
{!gameState.worldType && selectionStage === 'custom-world-agent' && (
<motion.div
key="custom-world-agent"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CustomWorldAgentWorkspace
session={
isRestoringAgentSession ? null : activeCustomWorldAgentSession
}
activeOperation={activeCustomWorldAgentOperation}
onBack={() => {
void leaveCustomWorldAgentWorkspace();
}}
onRefresh={() => {
void handleRefreshCustomWorldAgentSession();
}}
onSubmitMessage={(payload) => {
void handleSubmitCustomWorldAgentMessage(payload);
}}
onExecuteAction={(payload) => {
void handleExecuteCustomWorldAgentAction(payload);
}}
/>
</motion.div>
)}
</AnimatePresence>
<DeveloperTeamModal
isOpen={showDeveloperTeamModal}
message={DEVELOPER_TEAM_MESSAGE}
onClose={() => setShowDeveloperTeamModal(false)}
/>
</>
);
}