1
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
||||
GameState,
|
||||
} from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { GameShellDialogueIndicator } from './types';
|
||||
import { GameCanvas } from '../GameCanvas';
|
||||
|
||||
export function GameShellCanvasStage({
|
||||
@@ -21,11 +22,7 @@ export function GameShellCanvasStage({
|
||||
visibleGameState: GameState;
|
||||
hideSelectionHero: boolean;
|
||||
canvasCompanionRenderStates: CompanionRenderState[];
|
||||
dialogueIndicator: {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
dialogueIndicator: GameShellDialogueIndicator | null;
|
||||
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
||||
sceneTransitionToken: number;
|
||||
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
|
||||
@@ -36,9 +33,9 @@ export function GameShellCanvasStage({
|
||||
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl">叙世</div>
|
||||
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base">GENARRATIVE</div>
|
||||
<div className="selection-hero-brand px-6 text-center">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -20,22 +20,7 @@ import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
|
||||
import { GameShellStoryPanels } from './GameShellStoryPanels';
|
||||
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
|
||||
|
||||
type AdventureStatistics = {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
};
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
export function GameShellMainContent({
|
||||
gameState,
|
||||
@@ -106,7 +91,7 @@ export function GameShellMainContent({
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: AdventureStatistics;
|
||||
adventureStatistics: GameShellAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
resetForSaveAndExit: () => void;
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {getLiveGamePlayTimeMs} from '../../data/runtimeStats';
|
||||
import {getWorldCampScenePreset} from '../../data/scenePresets';
|
||||
import type {StoryOption} from '../../types';
|
||||
import {UI_CHROME} from '../../uiAssets';
|
||||
import {GameShellCanvasStage} from './GameShellCanvasStage';
|
||||
import {GameShellMainContent} from './GameShellMainContent';
|
||||
import {GameShellOverlays} from './GameShellOverlays';
|
||||
import {type GameShellProps} from './types';
|
||||
import {useGameShellViewModel} from './useGameShellViewModel';
|
||||
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './useSceneTransitionModel';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { GameShellCanvasStage } from './GameShellCanvasStage';
|
||||
import { GameShellMainContent } from './GameShellMainContent';
|
||||
import { GameShellOverlays } from './GameShellOverlays';
|
||||
import type { GameShellProps } from './types';
|
||||
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
|
||||
|
||||
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
@@ -26,7 +19,6 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
@@ -46,18 +38,10 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -77,120 +61,24 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
} = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
} = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
isCharacterSelectionStage,
|
||||
shouldHideStoryOptions,
|
||||
hideSelectionHero,
|
||||
dialogueIndicator,
|
||||
characterChatSummaries,
|
||||
canvasCompanionRenderStates,
|
||||
adventureStatistics,
|
||||
handleSceneTransitionChoice,
|
||||
} = useGameShellRuntimeViewModel({
|
||||
session,
|
||||
story,
|
||||
companions,
|
||||
});
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
selectionStage !== 'start';
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
} as const;
|
||||
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
|
||||
),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(() => {
|
||||
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
|
||||
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
|
||||
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
}),
|
||||
[
|
||||
gameState.runtimeStats.itemsUsed,
|
||||
gameState.runtimeStats.hostileNpcsDefeated,
|
||||
gameState.runtimeStats.questsAccepted,
|
||||
gameState.runtimeStats.scenesTraveled,
|
||||
livePlayTimeMs,
|
||||
visibleGameState.companions.length,
|
||||
visibleGameState.currentScenePreset?.name,
|
||||
visibleGameState.playerCurrency,
|
||||
visibleGameState.playerInventory,
|
||||
visibleGameState.quests,
|
||||
visibleGameState.roster.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
}, [beginSceneTransition, handleChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { PanelLoadingFallback } from './GameShellLoaders';
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
const AdventurePanel = lazy(async () => {
|
||||
const module = await import('../AdventurePanel');
|
||||
@@ -38,22 +39,6 @@ const InventoryPanel = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
type AdventureStatistics = {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
};
|
||||
|
||||
export function GameShellStoryPanels({
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
@@ -102,7 +87,7 @@ export function GameShellStoryPanels({
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: AdventureStatistics;
|
||||
adventureStatistics: GameShellAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
onSaveAndExit: () => void;
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
|
||||
import {
|
||||
readSavedCustomWorldProfiles,
|
||||
upsertSavedCustomWorldProfile,
|
||||
@@ -11,7 +15,6 @@ import {
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
@@ -365,7 +368,9 @@ export function PreGameSelectionFlow({
|
||||
settingText:
|
||||
generatedCustomWorldProfile.settingText.trim() ||
|
||||
customWorldSettingPreview,
|
||||
creatorIntent: generatedCustomWorldProfile.creatorIntent,
|
||||
creatorIntent:
|
||||
(generatedCustomWorldProfile.creatorIntent as JsonObject | null) ??
|
||||
null,
|
||||
generationMode:
|
||||
options.generationMode ??
|
||||
generatedCustomWorldProfile.generationMode ??
|
||||
@@ -453,7 +458,7 @@ export function PreGameSelectionFlow({
|
||||
const profile = await generateCustomWorldProfile(
|
||||
{
|
||||
settingText,
|
||||
creatorIntent: customWorldCreatorIntent,
|
||||
creatorIntent: customWorldCreatorIntent as unknown as JsonObject,
|
||||
generationMode: customWorldGenerationMode,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,28 @@ export interface GameShellAudioProps {
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface GameShellDialogueIndicator {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
}
|
||||
|
||||
export interface GameShellAdventureStatistics {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
}
|
||||
|
||||
export interface GameShellProps {
|
||||
session: GameShellSessionProps;
|
||||
story: GameShellStoryProps;
|
||||
|
||||
289
src/components/game-shell/useGameShellRuntimeViewModel.test.ts
Normal file
289
src/components/game-shell/useGameShellRuntimeViewModel.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CharacterChatRecord, CompanionRenderState, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import {
|
||||
buildAdventureStatistics,
|
||||
buildCanvasCompanionRenderStates,
|
||||
buildCharacterChatSummaries,
|
||||
buildGameShellDialogueIndicator,
|
||||
} from './useGameShellRuntimeViewModel';
|
||||
|
||||
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('useGameShellRuntimeViewModel 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(
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading: true,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toEqual({
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: 'player',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildGameShellDialogueIndicator({
|
||||
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,
|
||||
inventoryItemCount: 5,
|
||||
inventoryStackCount: 2,
|
||||
activeCompanionCount: 2,
|
||||
rosterCompanionCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/components/game-shell/useGameShellRuntimeViewModel.ts
Normal file
235
src/components/game-shell/useGameShellRuntimeViewModel.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user