init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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';

View 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>
);
}

View 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;
}

View 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,
};
}

View File

@@ -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,
});
});
});

View 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
>;

View 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,
};
}