Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -20,12 +20,8 @@ import {
|
||||
import {
|
||||
type CustomWorldSceneImageResult,
|
||||
generateCustomWorldSceneImage,
|
||||
} from '../services/ai';
|
||||
} from '../services/aiService';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
} from '../services/customWorld';
|
||||
import {
|
||||
AnimationState,
|
||||
CustomWorldLandmark,
|
||||
|
||||
@@ -217,7 +217,7 @@ export function CustomWorldResultView({
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存并进入世界</span>
|
||||
<span className="text-sm font-semibold text-white">保存到我的作品</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -55,6 +56,7 @@ interface GameShellStoryProps {
|
||||
|
||||
interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
@@ -208,6 +210,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
} = story;
|
||||
const {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
@@ -272,7 +275,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
!gameState.playerCharacter;
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
selectionStage !== 'start';
|
||||
selectionStage !== 'platform';
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
@@ -428,6 +431,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
@@ -447,7 +451,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('world');
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
@@ -61,6 +62,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||
@@ -304,6 +306,19 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
}, [showAccountModal, status]);
|
||||
|
||||
const authUiValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
openAccountModal: () => setShowAccountModal(true),
|
||||
logout: async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
},
|
||||
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
|
||||
}),
|
||||
[user],
|
||||
);
|
||||
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
@@ -468,140 +483,144 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => setShowAccountModal(true)}
|
||||
>
|
||||
{user.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||
onClick={() => {
|
||||
void logoutAuthUser();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
onClose={() => setShowAccountModal(false)}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
<AuthUiContext.Provider value={authUiValue}>
|
||||
<div className="relative">
|
||||
{showGlobalAccountActions ? (
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => setShowAccountModal(true)}
|
||||
>
|
||||
{user.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||
onClick={() => {
|
||||
void logoutAuthUser();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
onClose={() => setShowAccountModal(false)}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/components/auth/AuthUiContext.ts
Normal file
16
src/components/auth/AuthUiContext.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
|
||||
type AuthUiContextValue = {
|
||||
user: AuthUser | null;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);
|
||||
|
||||
export function useAuthUi() {
|
||||
return useContext(AuthUiContext);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import type { BottomTab } from '../../hooks/useGameFlow';
|
||||
import type {
|
||||
@@ -8,6 +9,7 @@ import type {
|
||||
InventoryFlowUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type {
|
||||
CompanionRenderState,
|
||||
CustomWorldProfile,
|
||||
@@ -17,11 +19,38 @@ import type {
|
||||
} from '../../types';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
|
||||
import { GameShellStoryPanels } from './GameShellStoryPanels';
|
||||
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
|
||||
import type { SelectionStage } from './PreGameSelectionFlow';
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
const CharacterSelectionFlow = lazy(async () => {
|
||||
const module = await import('./CharacterSelectionFlow');
|
||||
return {
|
||||
default: module.CharacterSelectionFlow,
|
||||
};
|
||||
});
|
||||
const PreGameSelectionFlow = lazy(async () => {
|
||||
const module = await import('./PreGameSelectionFlow');
|
||||
return {
|
||||
default: module.PreGameSelectionFlow,
|
||||
};
|
||||
});
|
||||
const GameShellStoryPanels = lazy(async () => {
|
||||
const module = await import('./GameShellStoryPanels');
|
||||
return {
|
||||
default: module.GameShellStoryPanels,
|
||||
};
|
||||
});
|
||||
|
||||
function MainContentLoadingFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameShellMainContent({
|
||||
gameState,
|
||||
visibleGameState,
|
||||
@@ -34,6 +63,7 @@ export function GameShellMainContent({
|
||||
setSelectionStage,
|
||||
isCharacterSelectionStage,
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleCustomWorldSelect,
|
||||
@@ -71,6 +101,7 @@ export function GameShellMainContent({
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
isCharacterSelectionStage: boolean;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
@@ -110,15 +141,20 @@ export function GameShellMainContent({
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!gameState.worldType && (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={<MainContentLoadingFallback label="正在加载平台首页..." />}
|
||||
>
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{gameState.worldType && !gameState.playerCharacter && (
|
||||
@@ -129,50 +165,58 @@ export function GameShellMainContent({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<CharacterSelectionFlow
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('world');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={<MainContentLoadingFallback label="正在加载角色选择..." />}
|
||||
>
|
||||
<CharacterSelectionFlow
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{visibleGameState.playerCharacter && visibleCurrentStory && (
|
||||
<motion.div key="story-flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full min-h-0 flex-col">
|
||||
<GameShellStoryPanels
|
||||
visibleGameState={visibleGameState}
|
||||
visibleCurrentStory={visibleCurrentStory}
|
||||
isLoading={isLoading}
|
||||
aiError={aiError}
|
||||
bottomTab={bottomTab}
|
||||
setBottomTab={setBottomTab}
|
||||
displayedOptions={displayedOptions}
|
||||
hideStoryOptions={hideStoryOptions}
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
handleRefreshOptions={handleRefreshOptions}
|
||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
openOverlayPanel={openOverlayPanel}
|
||||
openCampModal={openCampModal}
|
||||
openPartyMemberDetails={openPartyMemberDetails}
|
||||
adventureStatistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={() => {
|
||||
resetForSaveAndExit();
|
||||
handleSaveAndExit();
|
||||
}}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={<MainContentLoadingFallback label="正在加载冒险面板..." />}
|
||||
>
|
||||
<GameShellStoryPanels
|
||||
visibleGameState={visibleGameState}
|
||||
visibleCurrentStory={visibleCurrentStory}
|
||||
isLoading={isLoading}
|
||||
aiError={aiError}
|
||||
bottomTab={bottomTab}
|
||||
setBottomTab={setBottomTab}
|
||||
displayedOptions={displayedOptions}
|
||||
hideStoryOptions={hideStoryOptions}
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
handleRefreshOptions={handleRefreshOptions}
|
||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
openOverlayPanel={openOverlayPanel}
|
||||
openCampModal={openCampModal}
|
||||
openPartyMemberDetails={openPartyMemberDetails}
|
||||
adventureStatistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={() => {
|
||||
resetForSaveAndExit();
|
||||
handleSaveAndExit();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { GameShellCanvasStage } from './GameShellCanvasStage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { GameShellMainContent } from './GameShellMainContent';
|
||||
import { GameShellOverlays } from './GameShellOverlays';
|
||||
import type { GameShellProps } from './types';
|
||||
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
|
||||
|
||||
const GameShellOverlays = lazy(async () => {
|
||||
const module = await import('./GameShellOverlays');
|
||||
return {
|
||||
default: module.GameShellOverlays,
|
||||
};
|
||||
});
|
||||
const GameShellCanvasStage = lazy(async () => {
|
||||
const module = await import('./GameShellCanvasStage');
|
||||
return {
|
||||
default: module.GameShellCanvasStage,
|
||||
};
|
||||
});
|
||||
|
||||
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const {
|
||||
gameState,
|
||||
isLoading,
|
||||
@@ -29,6 +44,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
} = story;
|
||||
const {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
@@ -80,6 +96,14 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
companions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter));
|
||||
|
||||
return () => {
|
||||
authUi?.setGlobalAccountActionsVisible(true);
|
||||
};
|
||||
}, [authUi, gameState.playerCharacter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
||||
@@ -89,18 +113,20 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
<GameShellCanvasStage
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
hideSelectionHero={hideSelectionHero}
|
||||
canvasCompanionRenderStates={canvasCompanionRenderStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
setSelectedSceneEntity={setSelectedSceneEntity}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
setSceneTransitionDurations={setSceneTransitionDurations}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<GameShellCanvasStage
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
hideSelectionHero={hideSelectionHero}
|
||||
canvasCompanionRenderStates={canvasCompanionRenderStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
setSelectedSceneEntity={setSelectedSceneEntity}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
setSceneTransitionDurations={setSceneTransitionDurations}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<GameShellMainContent
|
||||
gameState={gameState}
|
||||
@@ -114,6 +140,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
setSelectionStage={setSelectionStage}
|
||||
isCharacterSelectionStage={isCharacterSelectionStage}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
@@ -141,34 +168,35 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
handleSaveAndExit={handleSaveAndExit}
|
||||
/>
|
||||
|
||||
<GameShellOverlays
|
||||
gameState={gameState}
|
||||
isLoading={isLoading}
|
||||
isMapOpen={isMapOpen}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
npcUi={npcUi}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
overlayPanel={overlayPanel}
|
||||
closeOverlayPanel={closeOverlayPanel}
|
||||
openCampModal={openCampModal}
|
||||
openPartyMemberDetails={openPartyMemberDetails}
|
||||
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
|
||||
selectedSceneEntity={selectedSceneEntity}
|
||||
closeAdventureEntityModal={closeAdventureEntityModal}
|
||||
shouldMountCampModal={shouldMountCampModal}
|
||||
showTeamModal={showTeamModal}
|
||||
closeCampModal={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateRosterCompanion={onActivateRosterCompanion}
|
||||
shouldMountMapModal={shouldMountMapModal}
|
||||
handleMapTravelToScene={handleMapTravelToScene}
|
||||
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
|
||||
shouldMountNpcModals={shouldMountNpcModals}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<GameShellOverlays
|
||||
gameState={gameState}
|
||||
isLoading={isLoading}
|
||||
isMapOpen={isMapOpen}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
npcUi={npcUi}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
overlayPanel={overlayPanel}
|
||||
closeOverlayPanel={closeOverlayPanel}
|
||||
openCampModal={openCampModal}
|
||||
openPartyMemberDetails={openPartyMemberDetails}
|
||||
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
|
||||
selectedSceneEntity={selectedSceneEntity}
|
||||
closeAdventureEntityModal={closeAdventureEntityModal}
|
||||
shouldMountCampModal={shouldMountCampModal}
|
||||
showTeamModal={showTeamModal}
|
||||
closeCampModal={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateRosterCompanion={onActivateRosterCompanion}
|
||||
shouldMountMapModal={shouldMountMapModal}
|
||||
handleMapTravelToScene={handleMapTravelToScene}
|
||||
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
|
||||
shouldMountNpcModals={shouldMountNpcModals}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
356
src/components/game-shell/PlatformHomeView.tsx
Normal file
356
src/components/game-shell/PlatformHomeView.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './platformWorldPresentation';
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
detail,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
detail: string;
|
||||
actionLabel?: string;
|
||||
onAction?: (() => void) | null;
|
||||
}) {
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({
|
||||
text,
|
||||
}: {
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel rounded-[1.35rem] text-sm leading-6 text-zinc-300"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCard({
|
||||
entry,
|
||||
badge,
|
||||
metaLabel,
|
||||
onClick,
|
||||
}: {
|
||||
entry: PlatformWorldCardLike;
|
||||
badge: string;
|
||||
metaLabel: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="pixel-nine-slice pixel-pressable relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 14 })}
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.9))]" />
|
||||
<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-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
|
||||
{badge}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
{metaLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformHomeView({
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
featuredEntries,
|
||||
latestEntries,
|
||||
myEntries,
|
||||
isLoadingPlatform,
|
||||
platformError,
|
||||
onContinueGame,
|
||||
onRefresh,
|
||||
onOpenCreateWorld,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
}: {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
featuredEntries: CustomWorldGalleryCard[];
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
isLoadingPlatform: boolean;
|
||||
platformError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const snapshotWorldName =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
'继续冒险';
|
||||
const snapshotCharacterName =
|
||||
savedSnapshot?.gameState.playerCharacter?.title ??
|
||||
savedSnapshot?.gameState.playerCharacter?.name ??
|
||||
'旅人';
|
||||
const featuredShelf = featuredEntries.slice(0, 6);
|
||||
|
||||
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
|
||||
</div>
|
||||
<div className="truncate text-lg font-black text-white">
|
||||
自定义世界广场
|
||||
</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"
|
||||
>
|
||||
{authUi.user.displayName}
|
||||
</button>
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
278
src/components/game-shell/PlatformWorldDetailView.tsx
Normal file
278
src/components/game-shell/PlatformWorldDetailView.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './platformWorldPresentation';
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
tone = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'primary' | 'danger';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'primary'
|
||||
? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white'
|
||||
: tone === 'danger'
|
||||
? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformWorldDetailView({
|
||||
entry,
|
||||
isMutating,
|
||||
error,
|
||||
onBack,
|
||||
onStartGame,
|
||||
onContinueEdit,
|
||||
onPublish,
|
||||
onUnpublish,
|
||||
}: {
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
isMutating: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onStartGame: () => void;
|
||||
onContinueEdit?: (() => void) | null;
|
||||
onPublish?: (() => void) | null;
|
||||
onUnpublish?: (() => void) | null;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const previewCharacters = buildCustomWorldPlayableCharacters(entry.profile).slice(
|
||||
0,
|
||||
3,
|
||||
);
|
||||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
|
||||
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}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
返回广场
|
||||
</button>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300">
|
||||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div
|
||||
className="pixel-nine-slice relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.9))]" />
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
|
||||
{entry.authorDisplayName}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
|
||||
{entry.visibility === 'published'
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '仅自己可见'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-black text-white">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 max-w-[36rem] text-sm leading-7 text-zinc-200/88">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
世界信息
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
可玩角色
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">{entry.playableNpcCount}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
地标
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">{entry.landmarkCount}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
阵营
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
{entry.profile.majorFactions.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
冲突
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
{entry.profile.coreConflicts.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
关键角色
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewCharacters.map((character) => (
|
||||
<div
|
||||
key={character.id}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{character.title}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
|
||||
{character.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
关键场景
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewLandmarks.map((landmark) => (
|
||||
<div
|
||||
key={landmark.id}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{landmark.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
|
||||
{landmark.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
操作
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<ActionButton
|
||||
label="开始游戏"
|
||||
onClick={onStartGame}
|
||||
tone="primary"
|
||||
/>
|
||||
{onContinueEdit ? (
|
||||
<ActionButton
|
||||
label="继续创作"
|
||||
onClick={onContinueEdit}
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
{onPublish ? (
|
||||
<ActionButton
|
||||
label="发布到广场"
|
||||
onClick={onPublish}
|
||||
tone="primary"
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
{onUnpublish ? (
|
||||
<ActionButton
|
||||
label="下架作品"
|
||||
onClick={onUnpublish}
|
||||
tone="danger"
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
90
src/components/game-shell/platformWorldPresentation.ts
Normal file
90
src/components/game-shell/platformWorldPresentation.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return 'profile' in entry;
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
}
|
||||
|
||||
if (isLibraryWorldEntry(entry)) {
|
||||
return resolveCustomWorldCampSceneImage(entry.profile) ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldLeadPortrait(
|
||||
entry: PlatformWorldCardLike,
|
||||
) {
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? '';
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
`${entry.playableNpcCount} 角色`,
|
||||
`${entry.landmarkCount} 地标`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...entry.profile.majorFactions.slice(0, 2),
|
||||
...entry.profile.coreConflicts.slice(0, 1),
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
export function formatPlatformWorldTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '未发布';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function describePlatformThemeLabel(themeMode: PlatformWorldCardLike['themeMode']) {
|
||||
switch (themeMode) {
|
||||
case 'martial':
|
||||
return '江湖';
|
||||
case 'arcane':
|
||||
return '灵脉';
|
||||
case 'machina':
|
||||
return '机巧';
|
||||
case 'tide':
|
||||
return '潮痕';
|
||||
case 'rift':
|
||||
return '裂界';
|
||||
default:
|
||||
return '回响';
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import type {
|
||||
QuestFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type {
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
CompanionRenderState,
|
||||
CustomWorldProfile,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
@@ -43,6 +44,7 @@ export interface GameShellStoryProps {
|
||||
|
||||
export interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
|
||||
@@ -153,7 +153,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
shellViewModel.selectionStage !== 'start';
|
||||
shellViewModel.selectionStage !== 'platform';
|
||||
|
||||
const dialogueIndicator = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useGameShellViewModel(params: {
|
||||
characterChatModalOpen,
|
||||
hasNpcModalOpen,
|
||||
} = params;
|
||||
const [selectionStage, setSelectionStage] = useState<SelectionStage>('start');
|
||||
const [selectionStage, setSelectionStage] = useState<SelectionStage>('platform');
|
||||
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
|
||||
const [selectedSceneEntity, setSelectedSceneEntity] = useState<GameCanvasEntitySelection | null>(null);
|
||||
const [showTeamModal, setShowTeamModal] = useState(false);
|
||||
@@ -56,13 +56,13 @@ export function useGameShellViewModel(params: {
|
||||
const openCampModal = () => setShowTeamModal(true);
|
||||
const closeCampModal = () => setShowTeamModal(false);
|
||||
|
||||
const resetSelectionFlow = () => setSelectionStage('start');
|
||||
const resetSelectionFlow = () => setSelectionStage('platform');
|
||||
|
||||
const resetForSaveAndExit = () => {
|
||||
setSelectedSceneEntity(null);
|
||||
setOverlayPanel(null);
|
||||
setShowTeamModal(false);
|
||||
setSelectionStage('start');
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user