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 && (
}>
)}
);
}