Files
Genarrative/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx
2026-04-27 22:50:18 +08:00

311 lines
10 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,
};
});
/**
* RPG 运行态总外壳。
* 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host
* 保持原有 UI 结构不变,只把真实实现迁入 RPG 域目录。
*/
export function RpgRuntimeShell({
session,
story,
entry,
companions,
audio,
chrome,
onExitTestRuntime,
}: 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 isTestRuntime = gameState.runtimeMode === 'test';
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={null}>
<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>
)}
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
top: 'calc(36vh - 3.25rem)',
}}
>
<button
type="button"
onClick={onExitTestRuntime}
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={null}>
<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;