import { ArrowLeft, Bookmark, ChevronRight, History, MessageSquareText, Send, Settings, SlidersHorizontal, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime'; import type { VisualNovelCharacterDraft, VisualNovelChoiceDraft, VisualNovelDialogueStep, VisualNovelNarrationStep, VisualNovelResultDraft, VisualNovelRunSnapshot, VisualNovelRuntimeActionRequest, VisualNovelRuntimeStep, VisualNovelSceneChangeStep, VisualNovelTransitionStep, } from '../../../packages/shared/src/contracts/visualNovel'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController'; import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData'; import { VisualNovelRuntimePanel, type VisualNovelRuntimePanelKind, } from './VisualNovelRuntimePanels'; type VisualNovelRuntimeShellProps = { draft?: VisualNovelResultDraft | null; run?: VisualNovelRunSnapshot | null; isBusy?: boolean; isSaving?: boolean; isLoadingArchives?: boolean; resumingWorldKey?: string | null; error?: string | null; embedded?: boolean; streamedSteps?: VisualNovelRuntimeStep[]; streamingText?: string; saveArchives?: ProfileSaveArchiveSummary[]; onBack: () => void; onSubmitAction?: (payload: VisualNovelRuntimeActionRequest) => void; onContinue?: () => void; onRegenerateHistoryEntry?: (entryId: string) => void; onSaveRun?: () => void; onResumeSaveArchive?: (worldKey: string) => void; onTextModeChange?: (enabled: boolean) => void; }; type VisualNovelDisplayState = { sceneStep: VisualNovelSceneChangeStep | null; narrationStep: VisualNovelNarrationStep | null; dialogueStep: VisualNovelDialogueStep | null; transitionStep: VisualNovelTransitionStep | null; choiceStep: VisualNovelRuntimeStep | null; }; function buildClientEventId(kind: string) { return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`; } function collectRuntimeSteps( run: VisualNovelRunSnapshot, streamedSteps: VisualNovelRuntimeStep[], ) { return [ ...run.history.flatMap((entry) => entry.steps), ...streamedSteps, ]; } function resolveLatestStep( steps: VisualNovelRuntimeStep[], type: T, ) { return [...steps].reverse().find((step) => step.type === type) as | Extract | undefined; } function resolveDisplayState( run: VisualNovelRunSnapshot, streamedSteps: VisualNovelRuntimeStep[], ): VisualNovelDisplayState { const steps = collectRuntimeSteps(run, streamedSteps); return { sceneStep: resolveLatestStep(steps, 'scene_change') ?? null, narrationStep: resolveLatestStep(steps, 'narration') ?? null, dialogueStep: resolveLatestStep(steps, 'dialogue') ?? null, transitionStep: resolveLatestStep(steps, 'transition') ?? null, choiceStep: resolveLatestStep(steps, 'choice') ?? null, }; } function resolveSceneId( run: VisualNovelRunSnapshot, sceneStep: VisualNovelSceneChangeStep | null, ) { return sceneStep?.sceneId ?? run.currentSceneId; } function resolveSceneName(draft: VisualNovelResultDraft, sceneId: string | null) { return draft.scenes.find((scene) => scene.sceneId === sceneId)?.name ?? ''; } function resolveSceneBackground( draft: VisualNovelResultDraft, sceneId: string | null, sceneStep: VisualNovelSceneChangeStep | null, ) { return ( sceneStep?.backgroundImageSrc ?? draft.scenes.find((scene) => scene.sceneId === sceneId)?.backgroundImageSrc ?? draft.coverImageSrc ); } function resolveVisibleCharacters( draft: VisualNovelResultDraft, run: VisualNovelRunSnapshot, latestDialogue: VisualNovelDialogueStep | null, ) { const visibleIds = new Set(run.visibleCharacterIds); if (latestDialogue?.characterId) { visibleIds.add(latestDialogue.characterId); } return Array.from(visibleIds) .map((characterId) => draft.characters.find((character) => character.characterId === characterId), ) .filter((character): character is VisualNovelCharacterDraft => Boolean(character), ) .slice(0, 3); } function resolveCharacterImage(character: VisualNovelCharacterDraft) { if (character.imageAssets.length === 0) { return null; } return ( character.imageAssets.find( (asset) => asset.expression === character.defaultExpression, )?.imageSrc ?? character.imageAssets[0]?.imageSrc ?? null ); } function VisualNovelCharacterStandee({ character, index, active, }: { character: VisualNovelCharacterDraft; index: number; active: boolean; }) { const imageSrc = resolveCharacterImage(character); const palette = index % 2 === 0 ? 'from-sky-100/90 via-slate-100/78 to-slate-300/72' : 'from-rose-100/90 via-zinc-100/78 to-stone-300/72'; return (
{imageSrc ? ( ) : (
)}
{character.name}
); } function buildTextModeLines( run: VisualNovelRunSnapshot, streamedSteps: VisualNovelRuntimeStep[], ) { return [...run.history.flatMap((entry) => entry.steps), ...streamedSteps] .filter((step) => step.type === 'narration' || step.type === 'dialogue') .map((step) => step.type === 'dialogue' ? `${step.characterName}: ${step.text}` : step.text, ) .join('\n'); } function resolveChoices( run: VisualNovelRunSnapshot, choiceStep: VisualNovelRuntimeStep | null, ) { if (choiceStep?.type === 'choice' && choiceStep.choices.length > 0) { return choiceStep.choices; } return run.availableChoices; } export function VisualNovelRuntimeShell({ draft = mockVisualNovelDraft, run = mockVisualNovelRun, isBusy = false, isSaving = false, isLoadingArchives = false, resumingWorldKey = null, error, embedded = false, streamedSteps = [], streamingText = '', saveArchives, onBack, onSubmitAction, onContinue, onRegenerateHistoryEntry, onSaveRun, onResumeSaveArchive, onTextModeChange, }: VisualNovelRuntimeShellProps) { const [activePanel, setActivePanel] = useState(null); const [freeText, setFreeText] = useState(''); const [localTextModeEnabled, setLocalTextModeEnabled] = useState( run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false, ); const displayDraft = draft ?? mockVisualNovelDraft; const baseRun = run ?? mockVisualNovelRun; const runtimeController = useVisualNovelRuntimeController({ draft: displayDraft, initialRun: baseRun, profileId: displayDraft.profileId, autoStart: false, }); const displayRun = runtimeController.run ?? baseRun; const displayBusy = isBusy || runtimeController.isBusy; const displaySaving = isSaving || runtimeController.isSaving; const displayLoadingArchives = isLoadingArchives || runtimeController.isLoadingArchives; const displayResumingWorldKey = resumingWorldKey ?? runtimeController.resumingWorldKey; const displayError = error ?? runtimeController.error; const displaySaveArchives = saveArchives ?? runtimeController.saveArchives; const displayStreamedSteps = streamedSteps.length > 0 ? streamedSteps : runtimeController.streamedSteps; const displayStreamingText = streamingText || runtimeController.streamingText; const displayState = useMemo( () => resolveDisplayState(displayRun, displayStreamedSteps), [displayRun, displayStreamedSteps], ); const textModeEnabled = localTextModeEnabled; const sceneId = resolveSceneId(displayRun, displayState.sceneStep); const sceneName = resolveSceneName(displayDraft, sceneId); const backgroundImageSrc = resolveSceneBackground( displayDraft, sceneId, displayState.sceneStep, ); const visibleCharacters = useMemo( () => resolveVisibleCharacters( displayDraft, displayRun, displayState.dialogueStep, ), [displayDraft, displayRun, displayState.dialogueStep], ); const choices = resolveChoices(displayRun, displayState.choiceStep); const canSubmitFreeText = displayDraft.runtimeConfig.allowFreeTextAction && freeText.trim() && !displayBusy; const canShowAttributes = displayDraft.runtimeConfig.attributePanelMode !== 'off'; const primarySpeaker = displayState.dialogueStep?.characterName || (displayState.narrationStep ? '旁白' : displayDraft.workTitle); const primaryText = displayState.dialogueStep?.text || displayState.narrationStep?.text || displayDraft.opening.narration || displayDraft.workDescription; const textModeLines = buildTextModeLines(displayRun, displayStreamedSteps); const loadRuntimeSaveArchives = runtimeController.loadSaveArchives; useEffect(() => { if (activePanel === 'save' && !saveArchives) { void loadRuntimeSaveArchives(); } }, [activePanel, loadRuntimeSaveArchives, saveArchives]); const updateTextMode = (enabled: boolean) => { setLocalTextModeEnabled(enabled); onTextModeChange?.(enabled); }; const submitChoice = (choice: VisualNovelChoiceDraft) => { if (displayBusy) { return; } const payload = { actionKind: 'choice', choiceId: choice.choiceId, clientEventId: buildClientEventId('choice'), } satisfies VisualNovelRuntimeActionRequest; if (onSubmitAction) { onSubmitAction(payload); return; } void runtimeController.submitAction(payload); }; const submitFreeText = () => { const text = freeText.trim(); if (!text || displayBusy) { return; } const payload = { actionKind: 'free_text', text, clientEventId: buildClientEventId('free-text'), } satisfies VisualNovelRuntimeActionRequest; if (onSubmitAction) { onSubmitAction(payload); } else { void runtimeController.submitAction(payload); } setFreeText(''); }; const continueRuntime = () => { if (displayBusy) { return; } if (onContinue) { onContinue(); return; } void runtimeController.continueRun(); }; const regenerateHistoryEntry = (entryId: string) => { if (onRegenerateHistoryEntry) { onRegenerateHistoryEntry(entryId); return; } void runtimeController.regenerateFromHistory(entryId); }; const saveRuntime = () => { if (onSaveRun) { onSaveRun(); return; } void runtimeController.saveCurrentRun(); }; const resumeArchive = (worldKey: string) => { if (onResumeSaveArchive) { onResumeSaveArchive(worldKey); return; } void runtimeController.resumeSaveArchive(worldKey); }; return (
{backgroundImageSrc ? ( ) : (
)}
{sceneName || displayDraft.workTitle}
{visibleCharacters.length > 0 ? ( visibleCharacters.map((character, index) => ( )) ) : (
)}
{primarySpeaker}
{displayRun.mode === 'test' ? 'TEST' : 'PLAY'}

{primaryText}

{displayState.transitionStep?.text ? (
{displayState.transitionStep.text}
) : null} {displayStreamingText ? (
{displayStreamingText}
) : null} {textModeEnabled ? (
{textModeLines || primaryText}
) : null}
{choices.length > 0 ? (
{choices.map((choice) => ( ))}
) : (
)}
setFreeText(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitFreeText(); } }} className="min-h-11 min-w-0 flex-1 rounded-full border border-white/16 bg-black/26 px-4 text-sm font-semibold text-white outline-none placeholder:text-white/42" placeholder="输入行动" aria-label="输入行动" />
{displayError ? (
{displayError}
) : null}
{[ { kind: 'history' as const, label: '历史', icon: History }, { kind: 'save' as const, label: '存档', icon: Bookmark }, { kind: 'settings' as const, label: '设置', icon: Settings }, ...(canShowAttributes ? [ { kind: 'attributes' as const, label: '属性', icon: SlidersHorizontal, }, ] : []), ].map((item) => { const Icon = item.icon; return ( ); })}
{activePanel ? ( setActivePanel(null)} onRegenerateHistoryEntry={regenerateHistoryEntry} onSaveRun={saveRuntime} onResumeSaveArchive={resumeArchive} textModeEnabled={textModeEnabled} onTextModeChange={updateTextMode} /> ) : null}
); } export default VisualNovelRuntimeShell;