This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -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>
) : (

View File

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

View File

@@ -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

View File

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

View File

@@ -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,
},
{

View File

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

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

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