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 { type BattleRewardUi, type CharacterChatUi, type GoalFlowUi, type InventoryFlowUi, type QuestFlowUi, type StoryGenerationNpcUi, } from '../hooks/useStoryGeneration'; import { type Character, type CustomWorldProfile, type CompanionRenderState, type GameState, type StoryMoment, type StoryOption, } from '../types'; import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets'; import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow'; import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow'; import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel'; import {useGameShellViewModel} from './game-shell/useGameShellViewModel'; import {GameCanvas} from './GameCanvas'; import {PixelIcon} from './PixelIcon'; interface GameShellSessionProps { gameState: GameState; currentStory: StoryMoment | null; isLoading: boolean; aiError: string | null; bottomTab: BottomTab; setBottomTab: (tab: BottomTab) => void; isMapOpen: boolean; setIsMapOpen: (open: boolean) => void; } interface GameShellStoryProps { displayedOptions: StoryOption[]; canRefreshOptions: boolean; handleRefreshOptions: () => void; handleChoice: (option: StoryOption) => void; handleNpcChatInput: (input: string) => boolean; exitNpcChat: () => boolean; handleMapTravelToScene: (sceneId: string) => boolean; npcUi: StoryGenerationNpcUi; characterChatUi: CharacterChatUi; inventoryUi: InventoryFlowUi; battleRewardUi: BattleRewardUi; questUi: QuestFlowUi; goalUi: GoalFlowUi; } interface GameShellEntryProps { hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; handleBackToWorldSelect: () => void; handleCharacterSelect: (character: Character) => void; } interface GameShellCompanionProps { companionRenderStates: CompanionRenderState[]; buildCompanionRenderStates: (state: GameState) => CompanionRenderState[]; onBenchCompanion: (npcId: string) => void; onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void; } interface GameShellAudioProps { musicVolume: number; onMusicVolumeChange: (value: number) => void; } interface GameShellProps { session: GameShellSessionProps; story: GameShellStoryProps; entry: GameShellEntryProps; companions: GameShellCompanionProps; audio: GameShellAudioProps; } const AdventureEntityModal = lazy(async () => { const module = await import('./AdventureEntityModal'); return { default: module.AdventureEntityModal, }; }); const CharacterChatModal = lazy(async () => { const module = await import('./CharacterChatModal'); return { default: module.CharacterChatModal, }; }); const CompanionCampModal = lazy(async () => { const module = await import('./CompanionCampModal'); return { default: module.CompanionCampModal, }; }); const MapModal = lazy(async () => { const module = await import('./MapModal'); return { default: module.MapModal, }; }); const NpcModals = lazy(async () => { const module = await import('./NpcModals'); return { default: module.NpcModals, }; }); const AdventurePanel = lazy(async () => { const module = await import('./AdventurePanel'); return { default: module.AdventurePanel, }; }); const CharacterPanel = lazy(async () => { const module = await import('./CharacterPanel'); return { default: module.CharacterPanel, }; }); const InventoryPanel = lazy(async () => { const module = await import('./InventoryPanel'); return { default: module.InventoryPanel, }; }); function ModalLoadingFallback({ label, onClose, }: { label: string; onClose?: (() => void) | null; }) { return (
event.stopPropagation()} > {label}
); } function PanelLoadingFallback({ label, }: { label: string; }) { return (
{label}
); } export function GameShell({session, story, entry, companions, audio}: GameShellProps) { const { gameState, currentStory, isLoading, aiError, bottomTab, setBottomTab, isMapOpen, setIsMapOpen, } = session; const { displayedOptions, canRefreshOptions, handleRefreshOptions, handleChoice, handleNpcChatInput, exitNpcChat, handleMapTravelToScene, npcUi, characterChatUi, inventoryUi, battleRewardUi, questUi, goalUi, } = story; const { hasSavedGame, savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, handleCustomWorldSelect, handleBackToWorldSelect, handleCharacterSelect, } = entry; const { companionRenderStates, buildCompanionRenderStates, onBenchCompanion, onActivateRosterCompanion, } = companions; const {musicVolume, onMusicVolumeChange} = audio; const [clockNow, setClockNow] = useState(() => Date.now()); const openingCampSceneId = useMemo( () => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null), [gameState.worldType], ); const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal); const { selectionStage, setSelectionStage, overlayPanel, openOverlayPanel, closeOverlayPanel, selectedSceneEntity, setSelectedSceneEntity, openPartyMemberDetails, closeAdventureEntityModal, showTeamModal, openCampModal, closeCampModal, resetForSaveAndExit, shouldMountAdventureEntityModal, shouldMountCampModal, shouldMountMapModal, shouldMountCharacterChatModal, shouldMountNpcModals, } = useGameShellViewModel({ gameState, isMapOpen, characterChatModalOpen: Boolean(characterChatUi.modal), hasNpcModalOpen, }); const { visibleGameState, visibleCurrentStory, sceneTransitionPhase, sceneTransitionToken, setSceneTransitionDurations, beginSceneTransition, } = useSceneTransitionModel({ gameState, currentStory, openingCampSceneId, }); const isCharacterSelectionStage = gameState.currentScene === 'Selection' && Boolean(gameState.worldType) && !gameState.playerCharacter; const collapseTopStage = gameState.currentScene === 'Selection'; const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const dialogueIndicator = useMemo(() => { if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') { return null; } const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null; return { showPlayer: true, showEncounter: true, activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, } as const; }, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]); const characterChatSummaries = useMemo( () => Object.fromEntries( Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]), ), [gameState.characterChats], ); const visibleCompanionRenderStates = useMemo( () => buildCompanionRenderStates(visibleGameState), [buildCompanionRenderStates, visibleGameState], ); const canvasCompanionRenderStates = useMemo(() => { const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc' ? visibleGameState.currentEncounter.id ?? null : null; if (!activeEncounterNpcId) return visibleCompanionRenderStates; return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId); }, [visibleCompanionRenderStates, visibleGameState.currentEncounter]); const livePlayTimeMs = useMemo( () => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow), [clockNow, gameState.runtimeStats], ); const adventureStatistics = useMemo( () => ({ playTimeMs: livePlayTimeMs, hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated, questsAccepted: gameState.runtimeStats.questsAccepted, questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length, questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length, itemsUsed: gameState.runtimeStats.itemsUsed, scenesTraveled: gameState.runtimeStats.scenesTraveled, currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area', playerCurrency: visibleGameState.playerCurrency, inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0), inventoryStackCount: visibleGameState.playerInventory.length, activeCompanionCount: visibleGameState.companions.length, rosterCompanionCount: visibleGameState.roster.length, }), [ gameState.runtimeStats.itemsUsed, gameState.runtimeStats.hostileNpcsDefeated, gameState.runtimeStats.questsAccepted, gameState.runtimeStats.scenesTraveled, livePlayTimeMs, visibleGameState.companions.length, visibleGameState.currentScenePreset?.name, visibleGameState.playerCurrency, visibleGameState.playerInventory, visibleGameState.quests, visibleGameState.roster.length, ], ); useEffect(() => { if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { return; } setClockNow(Date.now()); const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000); return () => window.clearInterval(intervalId); }, [gameState.currentScene, gameState.playerCharacter]); const handleSceneTransitionChoice = useCallback((option: StoryOption) => { const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId]; if (transitionMode) { beginSceneTransition(transitionMode); } handleChoice(option); }, [beginSceneTransition, handleChoice]); return (
{collapseTopStage ? null : ( setIsMapOpen(true)} sceneTransitionPhase={sceneTransitionPhase} sceneTransitionToken={sceneTransitionToken} onSceneTransitionDurationsChange={setSceneTransitionDurations} /> )}
{!gameState.worldType && ( )} {gameState.worldType && !gameState.playerCharacter && ( { handleBackToWorldSelect(); setSelectionStage('platform'); }} onConfirm={handleCharacterSelect} /> )} {visibleGameState.playerCharacter && visibleCurrentStory && (
{bottomTab === 'character' && ( }> )} {bottomTab === 'adventure' && ( }> openOverlayPanel('character')} onOpenInventory={() => openOverlayPanel('inventory')} playerCharacter={visibleGameState.playerCharacter} worldType={visibleGameState.worldType} quests={visibleGameState.quests} questUi={questUi} goalStack={goalUi.goalStack} goalPulse={goalUi.pulse} onDismissGoalPulse={goalUi.dismissPulse} battleRewardUi={battleRewardUi} playerHp={visibleGameState.playerHp} playerMaxHp={visibleGameState.playerMaxHp} playerMana={visibleGameState.playerMana} playerMaxMana={visibleGameState.playerMaxMana} playerSkillCooldowns={visibleGameState.playerSkillCooldowns} inBattle={visibleGameState.inBattle} currentNpcBattleMode={visibleGameState.currentNpcBattleMode} chapterState={visibleGameState.chapterState ?? null} journeyBeat={ visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null } statistics={adventureStatistics} musicVolume={musicVolume} onMusicVolumeChange={onMusicVolumeChange} onSaveAndExit={() => { resetForSaveAndExit(); handleSaveAndExit(); }} /> )} {bottomTab === 'inventory' && ( }> )}
)}
{shouldMountAdventureEntityModal && ( }> { closeAdventureEntityModal(); characterChatUi.openChat(target); }} /> )} {overlayPanel && gameState.playerCharacter && ( event.stopPropagation()} >
{overlayPanel === 'character' ? '队伍' : '背包'}
{overlayPanel === 'character' ? ( }> { closeOverlayPanel(); openCampModal(); }} onOpenCharacterChat={target => { closeOverlayPanel(); characterChatUi.openChat(target); }} chatSummaries={characterChatSummaries} onInspectMember={openPartyMemberDetails} /> ) : ( }> )}
)}
{shouldMountCampModal && ( }> )} {shouldMountMapModal && ( setIsMapOpen(false)} />}> { const triggered = handleMapTravelToScene(scene.id); if (triggered) { setIsMapOpen(false); } }} isTraveling={isLoading} onClose={() => setIsMapOpen(false)} /> )} {shouldMountCharacterChatModal && ( }> )} {shouldMountNpcModals && ( }> )}
); }