Files
Genarrative/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx
2026-05-21 20:20:06 +08:00

325 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useEffect } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import {
APP_RUNTIME_ROUTES,
pushAppHistoryPath,
} from '../../routing/appPageRoutes';
import { UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter';
import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types';
import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel';
const RpgRuntimeCanvasStage = lazy(async () => {
const module = await import('./RpgRuntimeCanvasStage');
return {
default: module.RpgRuntimeCanvasStage,
};
});
const RpgRuntimeOverlayHost = lazy(async () => {
const module = await import('./RpgRuntimeOverlayHost');
return {
default: module.RpgRuntimeOverlayHost,
};
});
function RuntimeLayerLoadingFallback({ label }: { label: string }) {
return (
<div className="pointer-events-none fixed inset-x-0 top-4 z-[24] flex justify-center px-4">
<div className="rounded-full border border-white/10 bg-black/55 px-4 py-2 text-xs text-zinc-200 shadow-[0_12px_30px_rgba(0,0,0,0.32)] backdrop-blur-sm">
{label}
</div>
</div>
);
}
/**
* RPG 运行态总外壳。
* 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host
* 保持原有 UI 结构不变,只把真实实现迁入 RPG 域目录。
*/
export function RpgRuntimeShell({
session,
story,
entry,
companions,
audio,
chrome,
onExitRuntimePreview,
showRuntimePreviewExit,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const {
gameState,
isLoading,
aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
} = session;
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleNpcChatInput,
refreshNpcChatOptions,
exitNpcChat,
handleMapTravelToScene,
npcUi,
characterChatUi,
inventoryUi,
battleRewardUi,
questUi,
npcChatQuestOfferUi,
goalUi,
} = story;
const {
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const { companionRenderStates, onBenchCompanion, onActivateRosterCompanion } =
companions;
const { musicVolume, onMusicVolumeChange } = audio;
const {
selectionStage,
setSelectionStage,
overlayPanel,
openOverlayPanel,
closeOverlayPanel,
selectedSceneEntity,
setSelectedSceneEntity,
openPartyMemberDetails,
closeAdventureEntityModal,
showTeamModal,
openCampModal,
closeCampModal,
resetForSaveAndExit,
shouldMountAdventureEntityModal,
shouldMountCampModal,
shouldMountMapModal,
shouldMountCharacterChatModal,
shouldMountNpcModals,
visibleGameState,
visibleCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
isCharacterSelectionStage,
shouldHideStoryOptions,
hideSelectionHero,
dialogueIndicator,
characterChatSummaries,
canvasCompanionRenderStates,
adventureStatistics,
handleSceneTransitionChoice,
} = useRpgRuntimeShellViewModel({
session,
story,
companions,
});
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
);
const playerProgressionRatio =
playerProgression.xpToNextLevel <= 0
? 1
: Math.max(
0,
Math.min(
1,
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
const canExitRuntimePreview =
Boolean(gameState.worldType) &&
Boolean(showRuntimePreviewExit) &&
Boolean(onExitRuntimePreview);
useEffect(() => {
if (gameState.worldType && !gameState.playerCharacter) {
pushAppHistoryPath(APP_RUNTIME_ROUTES['rpg-character-select']);
return;
}
if (visibleGameState.playerCharacter && visibleCurrentStory) {
pushAppHistoryPath(APP_RUNTIME_ROUTES['rpg-adventure']);
}
}, [
gameState.playerCharacter,
gameState.worldType,
visibleCurrentStory,
visibleGameState.playerCharacter,
]);
return (
<div
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
style={{
backgroundImage: isPlatformShell
? 'var(--platform-body-fill)'
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isPlatformShell ? undefined : 'center',
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
}}
>
{gameState.worldType ? (
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载场景" />}>
<RpgRuntimeCanvasStage
gameState={gameState}
visibleGameState={visibleGameState}
hideSelectionHero={hideSelectionHero}
canvasCompanionRenderStates={canvasCompanionRenderStates}
dialogueIndicator={dialogueIndicator}
sceneTransitionPhase={sceneTransitionPhase}
sceneTransitionToken={sceneTransitionToken}
setSelectedSceneEntity={setSelectedSceneEntity}
setIsMapOpen={setIsMapOpen}
setSceneTransitionDurations={setSceneTransitionDurations}
/>
</Suspense>
) : null}
{visibleGameState.playerCharacter && !chrome?.hidePlayerLevelBadge && (
<div
className="pointer-events-none fixed z-[26] w-[4.5rem] drop-shadow-[0_2px_8px_rgba(0,0,0,0.75)]"
style={{
top: 'calc(env(safe-area-inset-top, 0px) + 0.65rem)',
left: 'calc(env(safe-area-inset-left, 0px) + 0.7rem)',
}}
>
<div className="flex items-end gap-1.5 text-amber-50">
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-amber-100/80">
Lv
</span>
<span className="text-2xl font-black leading-none tracking-[-0.08em] text-white">
{playerProgression.level}
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-black/45">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.65),rgba(254,240,138,0.95))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(8, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
)}
{canExitRuntimePreview ? (
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
top: 'calc(36vh - 3.25rem)',
}}
>
<button
type="button"
onClick={onExitRuntimePreview}
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
>
</button>
</div>
) : null}
<RpgRuntimeStageRouter
gameState={gameState}
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
isLoading={isLoading}
aiError={aiError}
bottomTab={bottomTab}
setBottomTab={setBottomTab}
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
isCharacterSelectionStage={isCharacterSelectionStage}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleCustomWorldSelect={handleCustomWorldSelect}
handleBackToWorldSelect={handleBackToWorldSelect}
handleCharacterSelect={handleCharacterSelect}
displayedOptions={displayedOptions}
hideStoryOptions={shouldHideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
refreshNpcChatOptions={refreshNpcChatOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
npcChatQuestOfferUi={npcChatQuestOfferUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
adventureStatistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
resetForSaveAndExit={resetForSaveAndExit}
handleSaveAndExit={handleSaveAndExit}
/>
{gameState.worldType ? (
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载界面" />}>
<RpgRuntimeOverlayHost
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>
) : null}
</div>
);
}
export type RpgRuntimeShellProps = RpgRuntimeShellComponentProps;
export default RpgRuntimeShell;