236 lines
6.8 KiB
TypeScript
236 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
|
|
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
|
import type {
|
|
CharacterChatRecord,
|
|
CompanionRenderState,
|
|
GameState,
|
|
StoryMoment,
|
|
StoryOption,
|
|
} from '../../types';
|
|
import type {
|
|
GameShellAdventureStatistics,
|
|
GameShellDialogueIndicator,
|
|
GameShellProps,
|
|
} from './types';
|
|
import { useGameShellViewModel } from './useGameShellViewModel';
|
|
import {
|
|
SCENE_TRANSITION_FUNCTION_MODES,
|
|
useSceneTransitionModel,
|
|
} from './useSceneTransitionModel';
|
|
|
|
export function buildGameShellDialogueIndicator(params: {
|
|
isLoading: boolean;
|
|
visibleGameState: GameState;
|
|
visibleCurrentStory: StoryMoment | null;
|
|
}): GameShellDialogueIndicator | 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;
|
|
}): GameShellAdventureStatistics {
|
|
const { gameState, visibleGameState, livePlayTimeMs } = params;
|
|
|
|
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,
|
|
inventoryItemCount: visibleGameState.playerInventory.reduce(
|
|
(sum, item) => sum + item.quantity,
|
|
0,
|
|
),
|
|
inventoryStackCount: visibleGameState.playerInventory.length,
|
|
activeCompanionCount: visibleGameState.companions.length,
|
|
rosterCompanionCount: visibleGameState.roster.length,
|
|
};
|
|
}
|
|
|
|
export function useGameShellRuntimeViewModel(params: Pick<
|
|
GameShellProps,
|
|
'session' | 'story' | 'companions'
|
|
>) {
|
|
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 = useGameShellViewModel({
|
|
gameState,
|
|
isMapOpen,
|
|
characterChatModalOpen: Boolean(characterChatUi.modal),
|
|
hasNpcModalOpen,
|
|
});
|
|
const sceneTransitionModel = useSceneTransitionModel({
|
|
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' &&
|
|
shellViewModel.selectionStage !== 'start';
|
|
|
|
const dialogueIndicator = useMemo(
|
|
() =>
|
|
buildGameShellDialogueIndicator({
|
|
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,
|
|
};
|
|
}
|