808 lines
32 KiB
TypeScript
808 lines
32 KiB
TypeScript
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 {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
|
|
import {
|
|
type BattleRewardUi,
|
|
type CharacterChatUi,
|
|
type GoalFlowUi,
|
|
type InventoryFlowUi,
|
|
type NpcChatQuestOfferUi,
|
|
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;
|
|
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
|
goalUi: GoalFlowUi;
|
|
}
|
|
|
|
interface GameShellEntryProps {
|
|
hasSavedGame: boolean;
|
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
|
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => 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 (
|
|
<div
|
|
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
|
onClick={onClose ?? undefined}
|
|
>
|
|
<div
|
|
className="pixel-nine-slice pixel-modal-shell flex min-h-40 w-full max-w-md items-center justify-center px-6 py-8 text-center text-sm text-zinc-300 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
|
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
|
onClick={event => event.stopPropagation()}
|
|
>
|
|
{label}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PanelLoadingFallback({
|
|
label,
|
|
}: {
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<div className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500" style={getNineSliceStyle(UI_CHROME.modalPanel)}>
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
npcChatQuestOfferUi,
|
|
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 visibleStoryForRender = visibleCurrentStory;
|
|
|
|
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 activeSceneAct = useMemo(
|
|
() => resolveActiveSceneActBlueprint({
|
|
profile: visibleGameState.customWorldProfile,
|
|
sceneId: visibleGameState.currentScenePreset?.id ?? null,
|
|
storyEngineMemory: visibleGameState.storyEngineMemory,
|
|
}),
|
|
[
|
|
visibleGameState.currentScenePreset?.id,
|
|
visibleGameState.customWorldProfile,
|
|
visibleGameState.storyEngineMemory,
|
|
],
|
|
);
|
|
const activeSceneChapter = useMemo(() => {
|
|
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
|
|
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|
|
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
|
|
) ?? null
|
|
);
|
|
}, [
|
|
visibleGameState.currentScenePreset?.id,
|
|
visibleGameState.customWorldProfile,
|
|
]);
|
|
|
|
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 (
|
|
<div
|
|
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
|
style={{
|
|
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'repeat',
|
|
}}
|
|
>
|
|
<div className={`relative ${collapseTopStage ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
|
{collapseTopStage ? null : (
|
|
<GameCanvas
|
|
scrollWorld={visibleGameState.scrollWorld}
|
|
animationState={visibleGameState.animationState}
|
|
playerCharacter={visibleGameState.playerCharacter}
|
|
encounter={visibleGameState.currentEncounter}
|
|
currentScenePreset={visibleGameState.currentScenePreset}
|
|
worldType={visibleGameState.worldType}
|
|
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
|
playerX={visibleGameState.playerX}
|
|
playerOffsetY={visibleGameState.playerOffsetY}
|
|
playerFacing={visibleGameState.playerFacing}
|
|
playerActionMode={visibleGameState.playerActionMode}
|
|
inBattle={visibleGameState.inBattle}
|
|
playerHp={visibleGameState.playerHp}
|
|
playerMaxHp={visibleGameState.playerMaxHp}
|
|
playerMana={visibleGameState.playerMana}
|
|
playerMaxMana={visibleGameState.playerMaxMana}
|
|
activeCombatEffects={visibleGameState.activeCombatEffects}
|
|
companions={canvasCompanionRenderStates}
|
|
npcStates={visibleGameState.npcStates}
|
|
dialogueIndicator={dialogueIndicator}
|
|
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
|
|
onEntitySelect={setSelectedSceneEntity}
|
|
onSceneNameClick={() => setIsMapOpen(true)}
|
|
sceneTransitionPhase={sceneTransitionPhase}
|
|
sceneTransitionToken={sceneTransitionToken}
|
|
onSceneTransitionDurationsChange={setSceneTransitionDurations}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
|
style={{
|
|
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
|
|
backgroundImage: isCharacterSelectionStage
|
|
? undefined
|
|
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
|
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
|
|
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
|
|
}}
|
|
>
|
|
<AnimatePresence mode="wait">
|
|
{!gameState.worldType && (
|
|
<PreGameSelectionFlow
|
|
selectionStage={selectionStage}
|
|
setSelectionStage={setSelectionStage}
|
|
gameState={gameState}
|
|
hasSavedGame={hasSavedGame}
|
|
savedSnapshot={savedSnapshot}
|
|
handleContinueGame={handleContinueGame}
|
|
handleStartNewGame={handleStartNewGame}
|
|
handleCustomWorldSelect={handleCustomWorldSelect}
|
|
/>
|
|
)}
|
|
|
|
{gameState.worldType && !gameState.playerCharacter && (
|
|
<motion.div
|
|
key="character-select-shell"
|
|
initial={{opacity: 0, y: 12}}
|
|
animate={{opacity: 1, y: 0}}
|
|
exit={{opacity: 0, y: -12}}
|
|
className="flex h-full min-h-0 flex-col"
|
|
>
|
|
<CharacterSelectionFlow
|
|
worldType={gameState.worldType}
|
|
customWorldProfile={gameState.customWorldProfile}
|
|
onBack={() => {
|
|
handleBackToWorldSelect();
|
|
setSelectionStage('platform');
|
|
}}
|
|
onConfirm={handleCharacterSelect}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{visibleGameState.playerCharacter && visibleStoryForRender && (
|
|
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
|
|
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
|
|
<button
|
|
onClick={() => setBottomTab('character')}
|
|
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
|
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
|
>
|
|
<span className="pixel-tab-button__inner">
|
|
<PixelIcon
|
|
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
|
|
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
|
|
/>
|
|
<span className="pixel-tab-button__label">队伍</span>
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setBottomTab('adventure')}
|
|
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
|
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
|
>
|
|
<span className="pixel-tab-button__inner">
|
|
<PixelIcon
|
|
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
|
|
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
|
|
/>
|
|
<span className="pixel-tab-button__label">冒险</span>
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setBottomTab('inventory')}
|
|
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
|
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
|
>
|
|
<span className="pixel-tab-button__inner">
|
|
<PixelIcon
|
|
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
|
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
|
/>
|
|
<span className="pixel-tab-button__label">背包</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{bottomTab === 'character' && (
|
|
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
|
<CharacterPanel
|
|
worldType={visibleGameState.worldType}
|
|
customWorldProfile={visibleGameState.customWorldProfile}
|
|
playerCharacter={visibleGameState.playerCharacter}
|
|
playerHp={visibleGameState.playerHp}
|
|
playerMaxHp={visibleGameState.playerMaxHp}
|
|
playerMana={visibleGameState.playerMana}
|
|
playerMaxMana={visibleGameState.playerMaxMana}
|
|
playerEquipment={visibleGameState.playerEquipment}
|
|
activeBuildBuffs={visibleGameState.activeBuildBuffs}
|
|
companionRenderStates={companionRenderStates}
|
|
npcStates={visibleGameState.npcStates}
|
|
quests={visibleGameState.quests}
|
|
companionArcStates={
|
|
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
|
}
|
|
companionResolutions={
|
|
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
|
}
|
|
onOpenCamp={openCampModal}
|
|
onOpenCharacterChat={characterChatUi.openChat}
|
|
chatSummaries={characterChatSummaries}
|
|
onInspectMember={openPartyMemberDetails}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{bottomTab === 'adventure' && (
|
|
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
|
|
<AdventurePanel
|
|
aiError={aiError}
|
|
currentStory={visibleStoryForRender}
|
|
isLoading={isLoading}
|
|
displayedOptions={displayedOptions}
|
|
hideOptions={shouldHideStoryOptions}
|
|
canRefreshOptions={canRefreshOptions}
|
|
onRefreshOptions={handleRefreshOptions}
|
|
onChoice={handleSceneTransitionChoice}
|
|
onSubmitNpcChatInput={handleNpcChatInput}
|
|
onExitNpcChat={exitNpcChat}
|
|
onOpenCharacter={() => openOverlayPanel('character')}
|
|
onOpenInventory={() => openOverlayPanel('inventory')}
|
|
playerCharacter={visibleGameState.playerCharacter}
|
|
worldType={visibleGameState.worldType}
|
|
quests={visibleGameState.quests}
|
|
questUi={questUi}
|
|
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
|
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
|
|
}
|
|
currentSceneActTitle={activeSceneAct?.title ?? null}
|
|
currentSceneActIndex={
|
|
activeSceneChapter && activeSceneAct
|
|
? (() => {
|
|
const actIndex = activeSceneChapter.acts.findIndex(
|
|
act => act.id === activeSceneAct.id,
|
|
);
|
|
return actIndex >= 0 ? actIndex + 1 : null;
|
|
})()
|
|
: null
|
|
}
|
|
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
|
|
statistics={adventureStatistics}
|
|
musicVolume={musicVolume}
|
|
onMusicVolumeChange={onMusicVolumeChange}
|
|
onSaveAndExit={() => {
|
|
resetForSaveAndExit();
|
|
handleSaveAndExit();
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{bottomTab === 'inventory' && (
|
|
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
|
<InventoryPanel
|
|
playerCharacter={visibleGameState.playerCharacter}
|
|
worldType={visibleGameState.worldType}
|
|
playerInventory={visibleGameState.playerInventory}
|
|
playerCurrency={visibleGameState.playerCurrency}
|
|
playerHp={visibleGameState.playerHp}
|
|
playerMaxHp={visibleGameState.playerMaxHp}
|
|
playerMana={visibleGameState.playerMana}
|
|
playerMaxMana={visibleGameState.playerMaxMana}
|
|
inBattle={visibleGameState.inBattle}
|
|
onUseItem={inventoryUi.useInventoryItem}
|
|
onEquipItem={inventoryUi.equipInventoryItem}
|
|
forgeRecipes={inventoryUi.forgeRecipes}
|
|
onCraftRecipe={inventoryUi.craftRecipe}
|
|
onDismantleItem={inventoryUi.dismantleItem}
|
|
onReforgeItem={inventoryUi.reforgeItem}
|
|
continueGameDigest={
|
|
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
|
}
|
|
narrativeCodex={
|
|
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
|
}
|
|
narrativeQaReport={
|
|
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
|
}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{shouldMountAdventureEntityModal && (
|
|
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
|
|
<AdventureEntityModal
|
|
selection={selectedSceneEntity}
|
|
gameState={gameState}
|
|
onClose={closeAdventureEntityModal}
|
|
onOpenCharacterChat={target => {
|
|
closeAdventureEntityModal();
|
|
characterChatUi.openChat(target);
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
<AnimatePresence>
|
|
{overlayPanel && gameState.playerCharacter && (
|
|
<motion.div
|
|
initial={{opacity: 0}}
|
|
animate={{opacity: 1}}
|
|
exit={{opacity: 0}}
|
|
className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
|
onClick={closeOverlayPanel}
|
|
>
|
|
<motion.div
|
|
initial={{opacity: 0, scale: 0.96, y: 8}}
|
|
animate={{opacity: 1, scale: 1, y: 0}}
|
|
exit={{opacity: 0, scale: 0.96, y: 8}}
|
|
transition={{duration: 0.18, ease: 'easeOut'}}
|
|
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
|
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
|
onClick={event => event.stopPropagation()}
|
|
>
|
|
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
|
<div className="min-w-0 pr-10 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
|
|
<button
|
|
type="button"
|
|
onClick={closeOverlayPanel}
|
|
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
|
>
|
|
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 p-5">
|
|
{overlayPanel === 'character' ? (
|
|
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
|
<CharacterPanel
|
|
worldType={gameState.worldType}
|
|
customWorldProfile={gameState.customWorldProfile}
|
|
playerCharacter={gameState.playerCharacter}
|
|
playerHp={gameState.playerHp}
|
|
playerMaxHp={gameState.playerMaxHp}
|
|
playerMana={gameState.playerMana}
|
|
playerMaxMana={gameState.playerMaxMana}
|
|
playerEquipment={gameState.playerEquipment}
|
|
activeBuildBuffs={gameState.activeBuildBuffs}
|
|
companionRenderStates={companionRenderStates}
|
|
npcStates={gameState.npcStates}
|
|
quests={gameState.quests}
|
|
onOpenCamp={() => {
|
|
closeOverlayPanel();
|
|
openCampModal();
|
|
}}
|
|
onOpenCharacterChat={target => {
|
|
closeOverlayPanel();
|
|
characterChatUi.openChat(target);
|
|
}}
|
|
chatSummaries={characterChatSummaries}
|
|
onInspectMember={openPartyMemberDetails}
|
|
/>
|
|
</Suspense>
|
|
) : (
|
|
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
|
<InventoryPanel
|
|
playerCharacter={gameState.playerCharacter}
|
|
worldType={gameState.worldType}
|
|
playerInventory={gameState.playerInventory}
|
|
playerCurrency={gameState.playerCurrency}
|
|
playerHp={gameState.playerHp}
|
|
playerMaxHp={gameState.playerMaxHp}
|
|
playerMana={gameState.playerMana}
|
|
playerMaxMana={gameState.playerMaxMana}
|
|
inBattle={gameState.inBattle}
|
|
onUseItem={inventoryUi.useInventoryItem}
|
|
onEquipItem={inventoryUi.equipInventoryItem}
|
|
forgeRecipes={inventoryUi.forgeRecipes}
|
|
onCraftRecipe={inventoryUi.craftRecipe}
|
|
onDismantleItem={inventoryUi.dismantleItem}
|
|
onReforgeItem={inventoryUi.reforgeItem}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{shouldMountCampModal && (
|
|
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
|
|
<CompanionCampModal
|
|
isOpen={showTeamModal}
|
|
playerCharacter={gameState.playerCharacter}
|
|
companions={gameState.companions}
|
|
roster={gameState.roster}
|
|
inBattle={gameState.inBattle}
|
|
onClose={closeCampModal}
|
|
onBenchCompanion={onBenchCompanion}
|
|
onActivateCompanion={onActivateRosterCompanion}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{shouldMountMapModal && (
|
|
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
|
|
<MapModal
|
|
isOpen={isMapOpen}
|
|
currentScenePreset={gameState.currentScenePreset}
|
|
worldType={gameState.worldType}
|
|
canTravel={!gameState.inBattle && !isLoading}
|
|
onTravelToScene={scene => {
|
|
const triggered = handleMapTravelToScene(scene.id);
|
|
if (triggered) {
|
|
setIsMapOpen(false);
|
|
}
|
|
}}
|
|
isTraveling={isLoading}
|
|
onClose={() => setIsMapOpen(false)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{shouldMountCharacterChatModal && (
|
|
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
|
|
<CharacterChatModal
|
|
modal={characterChatUi.modal}
|
|
onClose={characterChatUi.closeChat}
|
|
onDraftChange={characterChatUi.setDraft}
|
|
onUseSuggestion={characterChatUi.useSuggestion}
|
|
onRefreshSuggestions={characterChatUi.refreshSuggestions}
|
|
onSendDraft={characterChatUi.sendDraft}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{shouldMountNpcModals && (
|
|
<Suspense fallback={<ModalLoadingFallback label="正在加载场景角色交互..." />}>
|
|
<NpcModals gameState={gameState} npcUi={npcUi} />
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|