Files
Genarrative/src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts
T
2026-05-05 14:40:41 +08:00

263 lines
7.8 KiB
TypeScript

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,
onDeferredAutoChoice: (option) => handleChoice(option),
});
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];
const shouldBeginTransition =
transitionMode &&
(option.functionId !== 'story_continue_adventure' ||
Boolean(
currentStory?.deferredAutoChoice ||
currentStory?.deferredRuntimeState,
));
if (shouldBeginTransition) {
beginSceneTransition(transitionMode);
}
handleChoice(option);
},
[
beginSceneTransition,
currentStory?.deferredAutoChoice,
currentStory?.deferredRuntimeState,
handleChoice,
],
);
return {
...shellViewModel,
...sceneTransitionModel,
isCharacterSelectionStage,
shouldHideStoryOptions,
hideSelectionHero,
dialogueIndicator,
characterChatSummaries,
canvasCompanionRenderStates,
adventureStatistics,
handleSceneTransitionChoice,
};
}
export type RpgRuntimeShellViewModelResult = ReturnType<
typeof useRpgRuntimeShellViewModel
>;