This commit is contained in:
77
src/components/rpg-runtime-shell/RpgRuntimeCanvasStage.tsx
Normal file
77
src/components/rpg-runtime-shell/RpgRuntimeCanvasStage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { CompanionRenderState, GameState } from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { GameCanvas } from '../GameCanvas';
|
||||
import type { RpgRuntimeDialogueIndicator } from './types';
|
||||
|
||||
export interface RpgRuntimeCanvasStageProps {
|
||||
gameState: GameState;
|
||||
visibleGameState: GameState;
|
||||
hideSelectionHero: boolean;
|
||||
canvasCompanionRenderStates: CompanionRenderState[];
|
||||
dialogueIndicator: RpgRuntimeDialogueIndicator | null;
|
||||
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
||||
sceneTransitionToken: number;
|
||||
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
|
||||
setIsMapOpen: (open: boolean) => void;
|
||||
setSceneTransitionDurations: (durations: {
|
||||
exitMs: number;
|
||||
entryMs: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态画布舞台真实入口。
|
||||
* 第三批收口后,运行态主链不再依赖旧 `GameShellCanvasStage` 命名。
|
||||
*/
|
||||
export function RpgRuntimeCanvasStage({
|
||||
gameState,
|
||||
visibleGameState,
|
||||
hideSelectionHero,
|
||||
canvasCompanionRenderStates,
|
||||
dialogueIndicator,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSelectedSceneEntity,
|
||||
setIsMapOpen,
|
||||
setSceneTransitionDurations,
|
||||
}: RpgRuntimeCanvasStageProps) {
|
||||
return (
|
||||
<div
|
||||
className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}
|
||||
>
|
||||
{hideSelectionHero ? null : (
|
||||
<GameCanvas
|
||||
scrollWorld={visibleGameState.scrollWorld}
|
||||
animationState={visibleGameState.animationState}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
customWorldProfile={visibleGameState.customWorldProfile}
|
||||
storyEngineMemory={visibleGameState.storyEngineMemory}
|
||||
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}
|
||||
onEntitySelect={setSelectedSceneEntity}
|
||||
onSceneNameClick={() => setIsMapOpen(true)}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
onSceneTransitionDurationsChange={setSceneTransitionDurations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgRuntimeCanvasStage;
|
||||
331
src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx
Normal file
331
src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import type {
|
||||
CharacterChatUi,
|
||||
InventoryFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/rpg-runtime-story';
|
||||
import type { CompanionRenderState, GameState } from '../../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import {
|
||||
ModalLoadingFallback,
|
||||
PanelLoadingFallback,
|
||||
} from './rpgRuntimeLoaders';
|
||||
|
||||
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 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,
|
||||
};
|
||||
});
|
||||
|
||||
export interface RpgRuntimeOverlayHostProps {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
isMapOpen: boolean;
|
||||
setIsMapOpen: (open: boolean) => void;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
overlayPanel: 'character' | 'inventory' | null;
|
||||
closeOverlayPanel: () => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
shouldMountAdventureEntityModal: boolean;
|
||||
selectedSceneEntity: GameCanvasEntitySelection | null;
|
||||
closeAdventureEntityModal: () => void;
|
||||
shouldMountCampModal: boolean;
|
||||
showTeamModal: boolean;
|
||||
closeCampModal: () => void;
|
||||
onBenchCompanion: (npcId: string) => void;
|
||||
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
|
||||
shouldMountMapModal: boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
shouldMountCharacterChatModal: boolean;
|
||||
shouldMountNpcModals: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态 overlay host。
|
||||
* 这里保留原有弹窗与独立面板装配方式,只把实现位置迁到 RPG 域目录。
|
||||
*/
|
||||
export function RpgRuntimeOverlayHost({
|
||||
gameState,
|
||||
isLoading,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
overlayPanel,
|
||||
closeOverlayPanel,
|
||||
openCampModal,
|
||||
openPartyMemberDetails,
|
||||
shouldMountAdventureEntityModal,
|
||||
selectedSceneEntity,
|
||||
closeAdventureEntityModal,
|
||||
shouldMountCampModal,
|
||||
showTeamModal,
|
||||
closeCampModal,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
shouldMountMapModal,
|
||||
handleMapTravelToScene,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
}: RpgRuntimeOverlayHostProps) {
|
||||
return (
|
||||
<>
|
||||
{shouldMountAdventureEntityModal && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<ModalLoadingFallback
|
||||
label="正在加载冒险详情..."
|
||||
onClose={closeAdventureEntityModal}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdventureEntityModal
|
||||
selection={selectedSceneEntity}
|
||||
gameState={gameState}
|
||||
onClose={closeAdventureEntityModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
/>
|
||||
</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}
|
||||
playerProgression={gameState.playerProgression ?? null}
|
||||
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}
|
||||
continueGameDigest={
|
||||
gameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
gameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
gameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgRuntimeOverlayHost;
|
||||
291
src/components/rpg-runtime-shell/RpgRuntimeShell.tsx
Normal file
291
src/components/rpg-runtime-shell/RpgRuntimeShell.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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,
|
||||
}: 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,
|
||||
),
|
||||
);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<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;
|
||||
262
src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx
Normal file
262
src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import type {
|
||||
BattleRewardUi,
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/rpg-runtime-story';
|
||||
import type { BottomTab } from '../../hooks/rpg-session';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type {
|
||||
CompanionRenderState,
|
||||
CustomWorldProfile,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||
import type { RpgAdventureStatistics } from './types';
|
||||
|
||||
const RpgEntryCharacterSelectView = lazy(async () => {
|
||||
const module = await import('../rpg-entry/RpgEntryCharacterSelectView');
|
||||
return {
|
||||
default: module.RpgEntryCharacterSelectView,
|
||||
};
|
||||
});
|
||||
|
||||
const PlatformEntryFlowShell = lazy(async () => {
|
||||
const module = await import('../platform-entry/PlatformEntryFlowShell');
|
||||
return {
|
||||
default: module.PlatformEntryFlowShell,
|
||||
};
|
||||
});
|
||||
|
||||
const RpgRuntimePanelRouter = lazy(async () => {
|
||||
const module = await import('../rpg-runtime-panels/RpgRuntimePanelRouter');
|
||||
return {
|
||||
default: module.RpgRuntimePanelRouter,
|
||||
};
|
||||
});
|
||||
|
||||
function MainContentLoadingFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RpgRuntimeStageRouterProps {
|
||||
gameState: GameState;
|
||||
visibleGameState: GameState;
|
||||
visibleCurrentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
aiError: string | null;
|
||||
bottomTab: BottomTab;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
isCharacterSelectionStage: boolean;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
|
||||
displayedOptions: StoryOption[];
|
||||
hideStoryOptions: boolean;
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
refreshNpcChatOptions: () => boolean;
|
||||
handleSceneTransitionChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: RpgAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
resetForSaveAndExit: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态主阶段路由器。
|
||||
* 这里只负责平台入口、角色选择、冒险运行态三个阶段的切换,不承接更细的面板实现。
|
||||
*/
|
||||
export function RpgRuntimeStageRouter({
|
||||
gameState,
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
isCharacterSelectionStage,
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
displayedOptions,
|
||||
hideStoryOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
refreshNpcChatOptions,
|
||||
handleSceneTransitionChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
openOverlayPanel,
|
||||
openCampModal,
|
||||
openPartyMemberDetails,
|
||||
adventureStatistics,
|
||||
musicVolume,
|
||||
onMusicVolumeChange,
|
||||
resetForSaveAndExit,
|
||||
handleSaveAndExit,
|
||||
}: RpgRuntimeStageRouterProps) {
|
||||
const isPlatformShell = !gameState.worldType;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 min-w-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : isPlatformShell ? 'p-2 sm:p-4' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
backgroundColor: isPlatformShell
|
||||
? 'transparent'
|
||||
: isCharacterSelectionStage
|
||||
? '#0d1016'
|
||||
: undefined,
|
||||
backgroundImage:
|
||||
isPlatformShell || isCharacterSelectionStage
|
||||
? undefined
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition:
|
||||
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat:
|
||||
isPlatformShell || isCharacterSelectionStage ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!gameState.worldType && (
|
||||
<Suspense
|
||||
fallback={<MainContentLoadingFallback label="正在加载平台首页..." />}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{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"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<MainContentLoadingFallback label="正在加载角色选择..." />}
|
||||
>
|
||||
<RpgEntryCharacterSelectView
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{visibleGameState.playerCharacter && visibleCurrentStory && (
|
||||
<motion.div
|
||||
key="story-flow"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<MainContentLoadingFallback label="正在加载冒险面板..." />}
|
||||
>
|
||||
<RpgRuntimePanelRouter
|
||||
visibleGameState={visibleGameState}
|
||||
visibleCurrentStory={visibleCurrentStory}
|
||||
isLoading={isLoading}
|
||||
aiError={aiError}
|
||||
bottomTab={bottomTab}
|
||||
setBottomTab={setBottomTab}
|
||||
displayedOptions={displayedOptions}
|
||||
hideStoryOptions={hideStoryOptions}
|
||||
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}
|
||||
onSaveAndExit={() => {
|
||||
resetForSaveAndExit();
|
||||
handleSaveAndExit();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgRuntimeStageRouter;
|
||||
36
src/components/rpg-runtime-shell/index.ts
Normal file
36
src/components/rpg-runtime-shell/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
RpgRuntimeCanvasStage,
|
||||
type RpgRuntimeCanvasStageProps,
|
||||
} from './RpgRuntimeCanvasStage';
|
||||
export {
|
||||
RpgRuntimeOverlayHost,
|
||||
type RpgRuntimeOverlayHostProps,
|
||||
} from './RpgRuntimeOverlayHost';
|
||||
export {
|
||||
RpgRuntimeShell,
|
||||
type RpgRuntimeShellProps,
|
||||
} from './RpgRuntimeShell';
|
||||
export {
|
||||
RpgRuntimeStageRouter,
|
||||
type RpgRuntimeStageRouterProps,
|
||||
} from './RpgRuntimeStageRouter';
|
||||
export {
|
||||
type RpgAdventureStatistics,
|
||||
type RpgRuntimeDialogueIndicator,
|
||||
} from './types';
|
||||
export {
|
||||
ModalLoadingFallback,
|
||||
PanelLoadingFallback,
|
||||
} from './rpgRuntimeLoaders';
|
||||
export {
|
||||
useRpgRuntimeShellViewModel,
|
||||
type RpgRuntimeShellViewModelResult,
|
||||
type UseRpgRuntimeShellViewModelParams,
|
||||
} from './useRpgRuntimeShellViewModel';
|
||||
export { useRpgRuntimeOverlayState } from './useRpgRuntimeOverlayState';
|
||||
export {
|
||||
SCENE_TRANSITION_FUNCTION_MODES,
|
||||
useRpgSceneTransitionModel,
|
||||
type SceneTransitionPhase,
|
||||
type SceneTransitionTriggerMode,
|
||||
} from './useRpgSceneTransitionModel';
|
||||
47
src/components/rpg-runtime-shell/rpgRuntimeLoaders.tsx
Normal file
47
src/components/rpg-runtime-shell/rpgRuntimeLoaders.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
/**
|
||||
* RPG 运行态模态加载占位。
|
||||
* 第三批收口后真实落点迁入 `rpg-runtime-shell`。
|
||||
*/
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态面板加载占位。
|
||||
* 仅迁移命名落点,不改 UI 表现。
|
||||
*/
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
110
src/components/rpg-runtime-shell/types.ts
Normal file
110
src/components/rpg-runtime-shell/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
BattleRewardUi,
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/rpg-runtime-story';
|
||||
import type { BottomTab } from '../../hooks/rpg-session';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type {
|
||||
Character,
|
||||
CompanionRenderState,
|
||||
CustomWorldProfile,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
export interface RpgRuntimeSessionProps {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
aiError: string | null;
|
||||
bottomTab: BottomTab;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
isMapOpen: boolean;
|
||||
setIsMapOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface RpgRuntimeStoryProps {
|
||||
displayedOptions: StoryOption[];
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
refreshNpcChatOptions: () => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
export interface RpgEntrySessionProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
|
||||
export interface RpgRuntimeCompanionProps {
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
|
||||
onBenchCompanion: (npcId: string) => void;
|
||||
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
|
||||
}
|
||||
|
||||
export interface RpgRuntimeAudioProps {
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface RpgRuntimeShellChromeOptions {
|
||||
hidePlayerLevelBadge?: boolean;
|
||||
}
|
||||
|
||||
export interface RpgRuntimeDialogueIndicator {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
}
|
||||
|
||||
export interface RpgAdventureStatistics {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
playerLevel?: number;
|
||||
playerCurrentLevelXp?: number;
|
||||
playerXpToNextLevel?: number;
|
||||
playerTotalXp?: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
}
|
||||
|
||||
export interface RpgRuntimeShellProps {
|
||||
session: RpgRuntimeSessionProps;
|
||||
story: RpgRuntimeStoryProps;
|
||||
entry: RpgEntrySessionProps;
|
||||
companions: RpgRuntimeCompanionProps;
|
||||
audio: RpgRuntimeAudioProps;
|
||||
chrome?: RpgRuntimeShellChromeOptions;
|
||||
}
|
||||
121
src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts
Normal file
121
src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
pushAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||
|
||||
type OverlayPanel = 'character' | 'inventory' | null;
|
||||
|
||||
function useLazyModalMount(active: boolean) {
|
||||
const [shouldMount, setShouldMount] = useState(active);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setShouldMount(true);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return shouldMount;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态 overlay 与独立面板状态。
|
||||
* 负责保留现有弹窗装配顺序,同时把壳层 UI 状态从旧 GameShell 命名迁出。
|
||||
*/
|
||||
export function useRpgRuntimeOverlayState(params: {
|
||||
gameState: GameState;
|
||||
isMapOpen: boolean;
|
||||
characterChatModalOpen: boolean;
|
||||
hasNpcModalOpen: boolean;
|
||||
}) {
|
||||
const {
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen,
|
||||
hasNpcModalOpen,
|
||||
} = params;
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
|
||||
const [selectedSceneEntity, setSelectedSceneEntity] =
|
||||
useState<GameCanvasEntitySelection | null>(null);
|
||||
const [showTeamModal, setShowTeamModal] = useState(false);
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
setRawSelectionStage(
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', syncStageFromHistory);
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSceneEntity(null);
|
||||
}, [gameState.currentScenePreset?.id, gameState.playerCharacter?.id]);
|
||||
|
||||
const shouldMountAdventureEntityModal = useLazyModalMount(
|
||||
Boolean(selectedSceneEntity),
|
||||
);
|
||||
const shouldMountCampModal = useLazyModalMount(showTeamModal);
|
||||
const shouldMountMapModal = useLazyModalMount(isMapOpen);
|
||||
const shouldMountCharacterChatModal = useLazyModalMount(
|
||||
characterChatModalOpen,
|
||||
);
|
||||
const shouldMountNpcModals = useLazyModalMount(hasNpcModalOpen);
|
||||
|
||||
const openOverlayPanel = (panel: Exclude<OverlayPanel, null>) => {
|
||||
setSelectedSceneEntity(null);
|
||||
setOverlayPanel(panel);
|
||||
};
|
||||
|
||||
const closeOverlayPanel = () => setOverlayPanel(null);
|
||||
const openPartyMemberDetails = (selection: GameCanvasEntitySelection) =>
|
||||
setSelectedSceneEntity(selection);
|
||||
const closeAdventureEntityModal = () => setSelectedSceneEntity(null);
|
||||
const openCampModal = () => setShowTeamModal(true);
|
||||
const closeCampModal = () => setShowTeamModal(false);
|
||||
|
||||
const resetSelectionFlow = () => setSelectionStage('platform');
|
||||
|
||||
const resetForSaveAndExit = () => {
|
||||
setSelectedSceneEntity(null);
|
||||
setOverlayPanel(null);
|
||||
setShowTeamModal(false);
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
return {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
resetSelectionFlow,
|
||||
overlayPanel,
|
||||
openOverlayPanel,
|
||||
closeOverlayPanel,
|
||||
selectedSceneEntity,
|
||||
setSelectedSceneEntity,
|
||||
openPartyMemberDetails,
|
||||
closeAdventureEntityModal,
|
||||
showTeamModal,
|
||||
openCampModal,
|
||||
closeCampModal,
|
||||
resetForSaveAndExit,
|
||||
shouldMountAdventureEntityModal,
|
||||
shouldMountCampModal,
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CharacterChatRecord, CompanionRenderState, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import {
|
||||
buildAdventureStatistics,
|
||||
buildCanvasCompanionRenderStates,
|
||||
buildCharacterChatSummaries,
|
||||
buildRpgRuntimeDialogueIndicator,
|
||||
} from '../rpg-runtime-shell/useRpgRuntimeShellViewModel';
|
||||
|
||||
function createBaseGameState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 3,
|
||||
questsAccepted: 2,
|
||||
itemsUsed: 4,
|
||||
scenesTraveled: 5,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '测试场景',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 18,
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '药草',
|
||||
description: '恢复道具',
|
||||
quantity: 2,
|
||||
category: 'consumable',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '布料',
|
||||
description: '材料',
|
||||
quantity: 3,
|
||||
category: 'material',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [
|
||||
{
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-1',
|
||||
issuerNpcName: '老周',
|
||||
sceneId: 'scene-1',
|
||||
title: '寻回包裹',
|
||||
description: '找回丢失的包裹',
|
||||
summary: '一项测试任务',
|
||||
objective: {
|
||||
kind: 'deliver_item',
|
||||
targetItemId: 'item-1',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
reward: {
|
||||
affinityBonus: 2,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '测试奖励',
|
||||
},
|
||||
{
|
||||
id: 'quest-2',
|
||||
issuerNpcId: 'npc-2',
|
||||
issuerNpcName: '阿青',
|
||||
sceneId: 'scene-1',
|
||||
title: '护送商队',
|
||||
description: '保护商队通行',
|
||||
summary: '另一项测试任务',
|
||||
objective: {
|
||||
kind: 'reach_scene',
|
||||
targetSceneId: 'scene-2',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'turned_in',
|
||||
reward: {
|
||||
affinityBonus: 3,
|
||||
currency: 20,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '测试奖励',
|
||||
},
|
||||
],
|
||||
roster: [
|
||||
{
|
||||
npcId: 'npc-roster',
|
||||
characterId: 'char-roster',
|
||||
joinedAtAffinity: 10,
|
||||
hp: 90,
|
||||
maxHp: 90,
|
||||
mana: 12,
|
||||
maxMana: 12,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-active',
|
||||
characterId: 'char-active',
|
||||
joinedAtAffinity: 18,
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 16,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
{
|
||||
npcId: 'npc-encounter',
|
||||
characterId: 'char-encounter',
|
||||
joinedAtAffinity: 22,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
mana: 14,
|
||||
maxMana: 14,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRpgRuntimeShellViewModel helpers', () => {
|
||||
it('builds a dialogue indicator only for active npc dialogue playback', () => {
|
||||
const state = {
|
||||
...createBaseGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-encounter',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
};
|
||||
const story = {
|
||||
text: '继续对话',
|
||||
displayMode: 'dialogue' as const,
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
text: '先别急着出手。',
|
||||
},
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: '那你想说什么?',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
} satisfies StoryMoment;
|
||||
|
||||
expect(
|
||||
buildRpgRuntimeDialogueIndicator({
|
||||
isLoading: true,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toEqual({
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: 'player',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildRpgRuntimeDialogueIndicator({
|
||||
isLoading: false,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('derives compact chat summaries and hides the active encounter companion from canvas renders', () => {
|
||||
const chatSummaries = buildCharacterChatSummaries({
|
||||
'char-active': {
|
||||
history: [],
|
||||
summary: '已经建立起稳定默契。',
|
||||
updatedAt: null,
|
||||
},
|
||||
'char-roster': {
|
||||
history: [],
|
||||
summary: '仍在营地观望局势。',
|
||||
updatedAt: null,
|
||||
},
|
||||
} satisfies Record<string, CharacterChatRecord>);
|
||||
|
||||
expect(chatSummaries).toEqual({
|
||||
'char-active': '已经建立起稳定默契。',
|
||||
'char-roster': '仍在营地观望局势。',
|
||||
});
|
||||
|
||||
const visibleCompanionRenderStates = [
|
||||
{ npcId: 'npc-active' },
|
||||
{ npcId: 'npc-encounter' },
|
||||
] as CompanionRenderState[];
|
||||
const visibleGameState = {
|
||||
...createBaseGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-encounter',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
buildCanvasCompanionRenderStates({
|
||||
visibleCompanionRenderStates,
|
||||
visibleGameState,
|
||||
}),
|
||||
).toEqual([{ npcId: 'npc-active' }]);
|
||||
});
|
||||
|
||||
it('aggregates adventure statistics from runtime and visible state slices', () => {
|
||||
const gameState = createBaseGameState();
|
||||
const statistics = buildAdventureStatistics({
|
||||
gameState,
|
||||
visibleGameState: gameState,
|
||||
livePlayTimeMs: 3210,
|
||||
});
|
||||
|
||||
expect(statistics).toEqual({
|
||||
playTimeMs: 3210,
|
||||
hostileNpcsDefeated: 3,
|
||||
questsAccepted: 2,
|
||||
questsCompleted: 2,
|
||||
questsTurnedIn: 1,
|
||||
itemsUsed: 4,
|
||||
scenesTraveled: 5,
|
||||
currentSceneName: '断桥旧哨',
|
||||
playerCurrency: 18,
|
||||
playerLevel: 1,
|
||||
playerCurrentLevelXp: 0,
|
||||
playerXpToNextLevel: 60,
|
||||
playerTotalXp: 0,
|
||||
inventoryItemCount: 5,
|
||||
inventoryStackCount: 2,
|
||||
activeCompanionCount: 2,
|
||||
rosterCompanionCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
249
src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts
Normal file
249
src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../../data/playerProgression';
|
||||
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import type {
|
||||
CharacterChatRecord,
|
||||
CompanionRenderState,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type {
|
||||
RpgAdventureStatistics,
|
||||
RpgRuntimeDialogueIndicator,
|
||||
RpgRuntimeShellProps,
|
||||
} from './types';
|
||||
import { useRpgRuntimeOverlayState } from './useRpgRuntimeOverlayState';
|
||||
import {
|
||||
SCENE_TRANSITION_FUNCTION_MODES,
|
||||
useRpgSceneTransitionModel,
|
||||
} from './useRpgSceneTransitionModel';
|
||||
|
||||
export function buildRpgRuntimeDialogueIndicator(params: {
|
||||
isLoading: boolean;
|
||||
visibleGameState: GameState;
|
||||
visibleCurrentStory: StoryMoment | null;
|
||||
}): RpgRuntimeDialogueIndicator | null {
|
||||
const { isLoading, visibleGameState, visibleCurrentStory } = params;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCharacterChatSummaries(
|
||||
characterChats: Record<string, CharacterChatRecord> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(characterChats ?? {}).map(([characterId, record]) => [
|
||||
characterId,
|
||||
record.summary,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCanvasCompanionRenderStates(params: {
|
||||
visibleCompanionRenderStates: CompanionRenderState[];
|
||||
visibleGameState: GameState;
|
||||
}) {
|
||||
const activeEncounterNpcId =
|
||||
params.visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? (params.visibleGameState.currentEncounter.id ?? null)
|
||||
: null;
|
||||
if (!activeEncounterNpcId) {
|
||||
return params.visibleCompanionRenderStates;
|
||||
}
|
||||
|
||||
return params.visibleCompanionRenderStates.filter(
|
||||
(companion) => companion.npcId !== activeEncounterNpcId,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAdventureStatistics(params: {
|
||||
gameState: GameState;
|
||||
visibleGameState: GameState;
|
||||
livePlayTimeMs: number;
|
||||
}): RpgAdventureStatistics {
|
||||
const { gameState, visibleGameState, livePlayTimeMs } = params;
|
||||
const playerProgression = normalizePlayerProgressionState(
|
||||
visibleGameState.playerProgression ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
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 ?? '当前区域',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
playerLevel: playerProgression.level,
|
||||
playerCurrentLevelXp: playerProgression.currentLevelXp,
|
||||
playerXpToNextLevel: playerProgression.xpToNextLevel,
|
||||
playerTotalXp: playerProgression.totalXp,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce(
|
||||
(sum, item) => sum + item.quantity,
|
||||
0,
|
||||
),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeShellViewModelParams = Pick<
|
||||
RpgRuntimeShellProps,
|
||||
'session' | 'story' | 'companions'
|
||||
>;
|
||||
|
||||
/**
|
||||
* RPG 运行态视图模型。
|
||||
* 负责拼装运行态 overlay 状态、过场可见态、画布 companion 数据和冒险统计。
|
||||
*/
|
||||
export function useRpgRuntimeShellViewModel(
|
||||
params: UseRpgRuntimeShellViewModelParams,
|
||||
) {
|
||||
const { session, story, companions } = params;
|
||||
const { gameState, currentStory, isLoading, isMapOpen } = session;
|
||||
const { npcUi, characterChatUi, handleChoice } = story;
|
||||
const { buildCompanionRenderStates } = companions;
|
||||
|
||||
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 shellViewModel = useRpgRuntimeOverlayState({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const sceneTransitionModel = useRpgSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
beginSceneTransition,
|
||||
} = sceneTransitionModel;
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero = gameState.currentScene === 'Selection';
|
||||
|
||||
const dialogueIndicator = useMemo(
|
||||
() =>
|
||||
buildRpgRuntimeDialogueIndicator({
|
||||
isLoading,
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
}),
|
||||
[isLoading, visibleCurrentStory, visibleGameState],
|
||||
);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() => buildCharacterChatSummaries(gameState.characterChats),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(
|
||||
() =>
|
||||
buildCanvasCompanionRenderStates({
|
||||
visibleCompanionRenderStates,
|
||||
visibleGameState,
|
||||
}),
|
||||
[visibleCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() =>
|
||||
buildAdventureStatistics({
|
||||
gameState,
|
||||
visibleGameState,
|
||||
livePlayTimeMs,
|
||||
}),
|
||||
[gameState, livePlayTimeMs, visibleGameState],
|
||||
);
|
||||
|
||||
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 {
|
||||
...shellViewModel,
|
||||
...sceneTransitionModel,
|
||||
isCharacterSelectionStage,
|
||||
shouldHideStoryOptions,
|
||||
hideSelectionHero,
|
||||
dialogueIndicator,
|
||||
characterChatSummaries,
|
||||
canvasCompanionRenderStates,
|
||||
adventureStatistics,
|
||||
handleSceneTransitionChoice,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgRuntimeShellViewModelResult = ReturnType<
|
||||
typeof useRpgRuntimeShellViewModel
|
||||
>;
|
||||
224
src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts
Normal file
224
src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
|
||||
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
|
||||
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
|
||||
|
||||
type SceneTransitionRequest = {
|
||||
mode: SceneTransitionTriggerMode;
|
||||
baselineSceneId: string | null;
|
||||
baselineContentKey: string;
|
||||
exitComplete: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SCENE_SWITCH_EXIT_MS = 5000;
|
||||
const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
|
||||
|
||||
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
|
||||
Record<string, SceneTransitionTriggerMode>
|
||||
> = {
|
||||
idle_travel_next_scene: 'scene-change',
|
||||
camp_travel_home_scene: 'scene-change',
|
||||
idle_explore_forward: 'content-change',
|
||||
idle_follow_clue: 'content-change',
|
||||
};
|
||||
|
||||
function buildSceneTransitionContentKey(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
|
||||
const encounterKey = gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
||||
: 'encounter:none';
|
||||
const monsterKey = gameState.sceneHostileNpcs
|
||||
.map(
|
||||
(monster) =>
|
||||
`${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`,
|
||||
)
|
||||
.join('|');
|
||||
const storyKey = currentStory
|
||||
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
|
||||
: 'story:none';
|
||||
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态场景过场模型真实入口。
|
||||
* 第三批收口后,`rpg-runtime-shell` 直接承载场景过场状态。
|
||||
*/
|
||||
export function useRpgSceneTransitionModel(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
openingCampSceneId: string | null;
|
||||
}) {
|
||||
const { gameState, currentStory, openingCampSceneId } = params;
|
||||
const [renderGameState, setRenderGameState] = useState(gameState);
|
||||
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
|
||||
const [sceneTransitionPhase, setSceneTransitionPhase] =
|
||||
useState<SceneTransitionPhase>('idle');
|
||||
const [sceneTransitionToken, setSceneTransitionToken] = useState(0);
|
||||
const [sceneTransitionDurations, setSceneTransitionDurations] = useState({
|
||||
exitMs: DEFAULT_SCENE_SWITCH_EXIT_MS,
|
||||
entryMs: DEFAULT_SCENE_SWITCH_ENTRY_MS,
|
||||
});
|
||||
|
||||
const pendingScenePayloadRef = useRef<{
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}>({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
|
||||
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||
window.clearTimeout(timerId),
|
||||
);
|
||||
sceneTransitionTimerIdsRef.current = [];
|
||||
sceneTransitionRequestRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startSceneEntering = useCallback(
|
||||
(payload: { gameState: GameState; currentStory: StoryMoment | null }) => {
|
||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||
window.clearTimeout(timerId),
|
||||
);
|
||||
sceneTransitionTimerIdsRef.current = [];
|
||||
sceneTransitionRequestRef.current = null;
|
||||
setRenderGameState(payload.gameState);
|
||||
setRenderCurrentStory(payload.currentStory);
|
||||
setSceneTransitionToken((current) => current + 1);
|
||||
setSceneTransitionPhase('entering');
|
||||
|
||||
const entryTimerId = window.setTimeout(() => {
|
||||
setSceneTransitionPhase('idle');
|
||||
}, sceneTransitionDurations.entryMs);
|
||||
sceneTransitionTimerIdsRef.current.push(entryTimerId);
|
||||
},
|
||||
[sceneTransitionDurations.entryMs],
|
||||
);
|
||||
|
||||
const beginSceneTransition = useCallback(
|
||||
(mode: SceneTransitionTriggerMode) => {
|
||||
if (sceneTransitionPhase !== 'idle') return;
|
||||
|
||||
pendingScenePayloadRef.current = { gameState, currentStory };
|
||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||
window.clearTimeout(timerId),
|
||||
);
|
||||
sceneTransitionTimerIdsRef.current = [];
|
||||
sceneTransitionRequestRef.current = {
|
||||
mode,
|
||||
baselineSceneId:
|
||||
renderGameState.currentScenePreset?.id ??
|
||||
gameState.currentScenePreset?.id ??
|
||||
null,
|
||||
baselineContentKey: buildSceneTransitionContentKey(
|
||||
renderGameState,
|
||||
renderCurrentStory,
|
||||
),
|
||||
exitComplete: false,
|
||||
};
|
||||
setSceneTransitionPhase('exiting');
|
||||
|
||||
const exitTimerId = window.setTimeout(() => {
|
||||
const request = sceneTransitionRequestRef.current;
|
||||
if (!request) return;
|
||||
request.exitComplete = true;
|
||||
|
||||
const pendingPayload = pendingScenePayloadRef.current;
|
||||
const isReady =
|
||||
request.mode === 'scene-change'
|
||||
? (pendingPayload.gameState.currentScenePreset?.id ?? null) !==
|
||||
request.baselineSceneId
|
||||
: buildSceneTransitionContentKey(
|
||||
pendingPayload.gameState,
|
||||
pendingPayload.currentStory,
|
||||
) !== request.baselineContentKey;
|
||||
|
||||
if (isReady) {
|
||||
startSceneEntering(pendingPayload);
|
||||
}
|
||||
}, sceneTransitionDurations.exitMs);
|
||||
sceneTransitionTimerIdsRef.current.push(exitTimerId);
|
||||
},
|
||||
[
|
||||
currentStory,
|
||||
gameState,
|
||||
renderCurrentStory,
|
||||
renderGameState,
|
||||
sceneTransitionDurations.exitMs,
|
||||
sceneTransitionPhase,
|
||||
startSceneEntering,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
pendingScenePayloadRef.current = { gameState, currentStory };
|
||||
|
||||
const request = sceneTransitionRequestRef.current;
|
||||
if (sceneTransitionPhase === 'exiting' && request?.exitComplete) {
|
||||
const isReady =
|
||||
request.mode === 'scene-change'
|
||||
? (gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId
|
||||
: buildSceneTransitionContentKey(gameState, currentStory) !==
|
||||
request.baselineContentKey;
|
||||
if (isReady) {
|
||||
startSceneEntering({ gameState, currentStory });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sceneTransitionPhase !== 'exiting') {
|
||||
setRenderGameState(gameState);
|
||||
setRenderCurrentStory(currentStory);
|
||||
}
|
||||
}, [currentStory, gameState, sceneTransitionPhase, startSceneEntering]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sceneTransitionPhase !== 'idle') {
|
||||
return;
|
||||
}
|
||||
if (renderGameState.playerCharacter) {
|
||||
return;
|
||||
}
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
if (gameState.storyHistory.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!openingCampSceneId ||
|
||||
gameState.currentScenePreset?.id !== openingCampSceneId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
startSceneEntering({ gameState, currentStory });
|
||||
}, [
|
||||
currentStory,
|
||||
gameState,
|
||||
openingCampSceneId,
|
||||
renderGameState.playerCharacter,
|
||||
sceneTransitionPhase,
|
||||
startSceneEntering,
|
||||
]);
|
||||
|
||||
return {
|
||||
visibleGameState:
|
||||
sceneTransitionPhase === 'idle' ? gameState : renderGameState,
|
||||
visibleCurrentStory:
|
||||
sceneTransitionPhase === 'idle' ? currentStory : renderCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user