608 lines
21 KiB
TypeScript
608 lines
21 KiB
TypeScript
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<T extends VisualNovelRuntimeStep['type']>(
|
|
steps: VisualNovelRuntimeStep[],
|
|
type: T,
|
|
) {
|
|
return [...steps].reverse().find((step) => step.type === type) as
|
|
| Extract<VisualNovelRuntimeStep, { type: T }>
|
|
| 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 (
|
|
<div
|
|
className={`flex min-w-0 flex-col items-center transition ${
|
|
active ? 'scale-100 opacity-100' : 'scale-[0.96] opacity-78'
|
|
}`}
|
|
>
|
|
{imageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={imageSrc}
|
|
alt={character.name}
|
|
className="h-[min(48dvh,21rem)] w-[9rem] object-contain drop-shadow-[0_26px_54px_rgba(0,0,0,0.42)] sm:w-[12rem]"
|
|
/>
|
|
) : (
|
|
<div
|
|
className={`h-[min(44dvh,18rem)] w-[7.5rem] rounded-t-full border border-white/18 bg-gradient-to-b ${palette} shadow-[0_26px_58px_rgba(15,23,42,0.32)] sm:w-[10rem]`}
|
|
/>
|
|
)}
|
|
<div className="mt-2 max-w-[8rem] truncate rounded-full border border-white/16 bg-black/28 px-3 py-1 text-xs font-black text-white backdrop-blur">
|
|
{character.name}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<VisualNovelRuntimePanelKind | null>(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 (
|
|
<main
|
|
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#111827] text-white`}
|
|
>
|
|
{backgroundImageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={backgroundImageSrc}
|
|
alt=""
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.10),rgba(15,23,42,0.92)),linear-gradient(135deg,#162235_0%,#334155_46%,#111827_100%)]" />
|
|
)}
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.88)),linear-gradient(90deg,rgba(0,0,0,0.32),transparent_36%,rgba(0,0,0,0.38))]" />
|
|
<div
|
|
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
|
|
style={{
|
|
boxSizing: 'border-box',
|
|
maxWidth: '100vw',
|
|
width: 'min(100vw, 64rem)',
|
|
}}
|
|
>
|
|
<header className="flex items-center justify-between gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur"
|
|
onClick={onBack}
|
|
aria-label="返回"
|
|
title="返回"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
<div className="min-w-0 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-center text-sm font-black backdrop-blur">
|
|
<span className="block max-w-[12rem] truncate sm:max-w-[22rem]">
|
|
{sceneName || displayDraft.workTitle}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur ${
|
|
textModeEnabled ? 'ring-2 ring-white/40' : ''
|
|
}`}
|
|
onClick={() => updateTextMode(!textModeEnabled)}
|
|
aria-label="文本模式"
|
|
title="文本模式"
|
|
>
|
|
<MessageSquareText size={18} />
|
|
</button>
|
|
</header>
|
|
|
|
<section className="relative mt-3 flex min-h-0 flex-1 items-end justify-center overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 px-3 pb-3 pt-6 shadow-[0_20px_54px_rgba(0,0,0,0.28)] backdrop-blur-sm">
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.08),transparent_34%),linear-gradient(90deg,rgba(15,23,42,0.22),transparent_36%,rgba(15,23,42,0.3))]" />
|
|
<div className="relative flex w-full max-w-4xl items-end justify-center gap-3 sm:gap-9">
|
|
{visibleCharacters.length > 0 ? (
|
|
visibleCharacters.map((character, index) => (
|
|
<VisualNovelCharacterStandee
|
|
key={character.characterId}
|
|
character={character}
|
|
index={index}
|
|
active={
|
|
!displayState.dialogueStep ||
|
|
displayState.dialogueStep.characterId ===
|
|
character.characterId
|
|
}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="h-[min(40dvh,16rem)]" />
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="relative mt-3 rounded-[1.25rem] border border-white/16 bg-black/46 p-3 shadow-[0_18px_44px_rgba(0,0,0,0.28)] backdrop-blur">
|
|
<div className="mb-2 flex min-w-0 items-center justify-between gap-3">
|
|
<div className="min-w-0 truncate text-sm font-black text-white">
|
|
{primarySpeaker}
|
|
</div>
|
|
<div className="shrink-0 text-[11px] font-bold text-white/58">
|
|
{displayRun.mode === 'test' ? 'TEST' : 'PLAY'}
|
|
</div>
|
|
</div>
|
|
<p className="m-0 min-h-[4.75rem] break-words text-base leading-7 text-white/92">
|
|
{primaryText}
|
|
</p>
|
|
{displayState.transitionStep?.text ? (
|
|
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm font-semibold text-white/76">
|
|
{displayState.transitionStep.text}
|
|
</div>
|
|
) : null}
|
|
{displayStreamingText ? (
|
|
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm leading-6 text-white/72">
|
|
{displayStreamingText}
|
|
</div>
|
|
) : null}
|
|
{textModeEnabled ? (
|
|
<div className="mt-3 max-h-32 overflow-y-auto rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 whitespace-pre-line text-sm leading-6 text-white/78">
|
|
{textModeLines || primaryText}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{choices.length > 0 ? (
|
|
<section className="mt-3 grid gap-2 sm:grid-cols-2">
|
|
{choices.map((choice) => (
|
|
<button
|
|
key={choice.choiceId}
|
|
type="button"
|
|
disabled={displayBusy}
|
|
onClick={() => submitChoice(choice)}
|
|
className="min-h-12 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 py-2 text-left text-sm font-black leading-5 text-white shadow-[0_10px_24px_rgba(0,0,0,0.18)] backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
|
|
>
|
|
<span className="block break-words">{choice.text}</span>
|
|
</button>
|
|
))}
|
|
</section>
|
|
) : (
|
|
<section className="mt-3">
|
|
<button
|
|
type="button"
|
|
disabled={displayBusy}
|
|
onClick={continueRuntime}
|
|
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 text-sm font-black text-white backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
|
|
>
|
|
<span>继续</span>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
<section className="mt-3 flex gap-2">
|
|
<input
|
|
value={freeText}
|
|
disabled={
|
|
displayBusy || !displayDraft.runtimeConfig.allowFreeTextAction
|
|
}
|
|
onChange={(event) => 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="输入行动"
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={!canSubmitFreeText}
|
|
onClick={submitFreeText}
|
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/16 bg-white/14 text-white backdrop-blur transition hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-55"
|
|
aria-label="发送行动"
|
|
title="发送行动"
|
|
>
|
|
<Send size={18} />
|
|
</button>
|
|
</section>
|
|
|
|
{displayError ? (
|
|
<div className="mt-3 rounded-[0.95rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
|
|
{displayError}
|
|
</div>
|
|
) : null}
|
|
|
|
<footer
|
|
className={`mt-3 grid gap-2 ${canShowAttributes ? 'grid-cols-4' : 'grid-cols-3'}`}
|
|
>
|
|
{[
|
|
{ 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 (
|
|
<button
|
|
key={item.kind}
|
|
type="button"
|
|
onClick={() => setActivePanel(item.kind)}
|
|
className="flex min-h-12 min-w-0 flex-col items-center justify-center gap-1 rounded-[0.95rem] border border-white/14 bg-black/24 px-2 text-xs font-black text-white backdrop-blur transition hover:bg-black/34"
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
<span className="max-w-full truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</footer>
|
|
</div>
|
|
|
|
{activePanel ? (
|
|
<VisualNovelRuntimePanel
|
|
kind={activePanel}
|
|
draft={displayDraft}
|
|
run={displayRun}
|
|
isBusy={displayBusy}
|
|
isSaving={displaySaving}
|
|
isLoadingArchives={displayLoadingArchives}
|
|
resumingWorldKey={displayResumingWorldKey}
|
|
saveArchives={displaySaveArchives}
|
|
allowRegeneration={displayDraft.runtimeConfig.allowHistoryRegeneration}
|
|
onClose={() => setActivePanel(null)}
|
|
onRegenerateHistoryEntry={regenerateHistoryEntry}
|
|
onSaveRun={saveRuntime}
|
|
onResumeSaveArchive={resumeArchive}
|
|
textModeEnabled={textModeEnabled}
|
|
onTextModeChange={updateTextMode}
|
|
/>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default VisualNovelRuntimeShell;
|