feat(play): add history dialog

Signed-off-by: baizhi958216 <1475289190@qq.com>
This commit is contained in:
baizhi958216
2026-06-06 20:52:10 +08:00
parent aef4771d2e
commit 5a7daa8452
5 changed files with 290 additions and 3 deletions
+2
View File
@@ -119,6 +119,8 @@ Use aliases from `tsconfig.json`: `@/*`, `@infiplot/engine`, `@infiplot/ai-clien
React components use PascalCase. Hooks, helpers, variables, and functions use camelCase. Types and interfaces use PascalCase. Route folders follow Next.js App Router conventions. UI work should follow the existing Tailwind-heavy visual language.
Modal/dialog UI should be extracted into dedicated components instead of being inlined inside large page or canvas components. Keep the host responsible for open/close state and domain data, and keep the modal component responsible for dialog layout, overlay behavior, keyboard close handling, scroll containers, and modal-specific styling.
Comment only non-obvious sequencing, provider quirks, fallback behavior, or architectural invariants.
## Configuration & Providers
+36
View File
@@ -52,6 +52,42 @@
text-transform: uppercase;
letter-spacing: 0.32em;
}
.vn-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(195, 155, 75, 0.58) rgba(20, 14, 8, 0.32);
}
.vn-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.vn-scrollbar::-webkit-scrollbar-track {
background:
linear-gradient(
to right,
transparent 0,
transparent 3px,
rgba(20, 14, 8, 0.46) 3px,
rgba(20, 14, 8, 0.46) 5px,
transparent 5px
);
}
.vn-scrollbar::-webkit-scrollbar-thumb {
background: rgba(195, 155, 75, 0.52);
border: 2px solid rgba(14, 10, 6, 0.88);
border-radius: 999px;
}
.vn-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(220, 180, 95, 0.76);
}
.vn-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
}
@keyframes infiplot-ripple {
+74 -1
View File
@@ -11,7 +11,11 @@ import {
useRef,
useState,
} from "react";
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
import {
PlayCanvas,
type Phase,
} from "@/components/PlayCanvas";
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
import { TtsKeyModal } from "@/components/TtsKeyModal";
import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
@@ -262,6 +266,63 @@ type ScenePathStep = {
exit: { choiceId: string; label: string; nextSceneSeed: string };
};
function buildDialogueHistory(
session: Session | null,
currentSceneId: string | undefined,
currentVisitedBeatIds: string[],
): DialogueHistoryItem[] {
if (!session) return [];
return session.history.flatMap((entry, sceneIndex) => {
const beatsById = new Map(entry.scene.beats.map((b) => [b.id, b]));
const visitedBeatIds =
entry.scene.id === currentSceneId
? currentVisitedBeatIds
: entry.visitedBeatIds;
return visitedBeatIds.flatMap((beatId, beatIndex) => {
const beat = beatsById.get(beatId);
if (!beat) return [];
const nextVisitedBeatId = visitedBeatIds[beatIndex + 1];
const choice =
beat.next.type === "choice"
? beat.next.choices.find((c) => {
if (c.effect.kind === "advance-beat") {
return c.effect.targetBeatId === nextVisitedBeatId;
}
return (
beatIndex === visitedBeatIds.length - 1 &&
entry.exit?.kind === "choice" &&
c.id === entry.exit.choiceId
);
})
: undefined;
const freeformAction =
beatIndex === visitedBeatIds.length - 1 &&
entry.exit?.kind === "freeform"
? entry.exit.action
: undefined;
const body = beat.speaker ? beat.line : beat.narration;
const narration = beat.speaker ? beat.narration : undefined;
if (!body && !narration && !choice && !freeformAction) return [];
return [
{
id: `${sceneIndex}:${beatId}:${beatIndex}`,
sceneIndex: sceneIndex + 1,
speaker: beat.speaker,
body,
narration,
selectedChoice: choice?.label,
freeformAction,
},
];
});
});
}
function pathKey(steps: ScenePathStep[]): string {
return steps.map((s) => s.exit.choiceId).join("/");
}
@@ -549,6 +610,16 @@ function PlayInner() {
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
}, [currentScene, currentBeatId]);
const dialogueHistory = useMemo<DialogueHistoryItem[]>(
() =>
buildDialogueHistory(
session,
currentScene?.id,
visitedBeatsRef.current,
),
[session, currentScene?.id, currentBeatId],
);
const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null;
useEffect(() => {
@@ -1369,6 +1440,7 @@ function PlayInner() {
onSelectChoice={onSelectChoice}
orientation={orientation}
fullViewport
dialogueHistory={dialogueHistory}
/>
{orientation === "portrait" && (
<div
@@ -1442,6 +1514,7 @@ function PlayInner() {
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
orientation={orientation}
dialogueHistory={dialogueHistory}
aboveCanvas={
<button
type="button"
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useEffect, useRef } from "react";
export type DialogueHistoryItem = {
id: string;
sceneIndex: number;
speaker?: string;
body?: string;
narration?: string;
selectedChoice?: string;
freeformAction?: string;
};
export function DialogueHistoryModal({
items,
portrait,
onClose,
}: {
items: DialogueHistoryItem[];
portrait: boolean;
onClose: () => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = listRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [items.length]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center px-4 py-6 pointer-events-auto"
style={{ background: "rgba(0,0,0,0.38)" }}
onClick={onClose}
>
<div
className={`w-full ${
portrait ? "max-w-[92vw]" : "max-w-2xl"
} max-h-[72dvh] overflow-hidden`}
onClick={(e) => e.stopPropagation()}
style={{
background: "rgba(14, 10, 6, 0.88)",
border: "1.5px solid rgba(175, 138, 72, 0.72)",
borderRadius: "6px",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
boxShadow:
"0 10px 42px rgba(0,0,0,0.62), inset 0 1px 0 rgba(200,165,90,0.12)",
}}
role="dialog"
aria-modal="true"
aria-label="剧情回溯"
>
<div className="flex items-center justify-between border-b border-cream-50/10 px-4 py-3">
<div className="flex items-center gap-2 text-[10px] smallcaps text-cream-50/70">
<i className="fa-solid fa-clock-rotate-left text-[10px]" />
· · ·
</div>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-cream-50/60 transition-colors hover:text-cream-50"
aria-label="关闭剧情回溯"
title="关闭"
>
<i className="fa-solid fa-xmark text-[12px]" />
</button>
</div>
<div
ref={listRef}
className={`vn-scrollbar overflow-y-auto px-4 py-3 ${
portrait ? "max-h-[58dvh]" : "max-h-[60dvh]"
}`}
>
{items.length === 0 ? (
<p className="py-8 text-center font-serif text-[13px] text-cream-50/55">
</p>
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="text-left">
<div className="mb-1 flex items-baseline gap-2">
<span className="text-[9px] smallcaps text-cream-50/35">
{String(item.sceneIndex).padStart(3, "0")}
</span>
{item.speaker && (
<span className="font-serif text-[12px] text-[rgba(205,165,90,0.92)]">
{item.speaker}
</span>
)}
</div>
{item.body && (
<p
className={`font-serif leading-[1.75] ${
portrait ? "text-[15px]" : "text-[13px]"
}`}
style={{ color: "rgba(245,235,210,0.94)" }}
>
{item.body}
</p>
)}
{item.narration && (
<p
className={`mt-1 font-serif italic leading-[1.65] ${
portrait ? "text-[13px]" : "text-[12px]"
}`}
style={{ color: "rgba(200,185,155,0.72)" }}
>
{item.narration}
</p>
)}
{item.selectedChoice && (
<p className="mt-2 inline-flex max-w-full items-start gap-2 rounded-[5px] border border-[rgba(180,140,80,0.35)] bg-[rgba(180,140,60,0.10)] px-2.5 py-1.5 font-serif text-[12px] leading-snug text-cream-50/85">
<span className="shrink-0 text-[rgba(195,155,75,0.9)]">
</span>
<span>{item.selectedChoice}</span>
</p>
)}
{item.freeformAction && (
<p className="mt-2 inline-flex max-w-full items-start gap-2 rounded-[5px] border border-ember-500/30 bg-ember-500/10 px-2.5 py-1.5 font-serif text-[12px] leading-snug text-cream-50/85">
<span className="shrink-0 text-ember-300/90">
</span>
<span>{item.freeformAction}</span>
</p>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+30 -2
View File
@@ -1,6 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
DialogueHistoryModal,
type DialogueHistoryItem,
} from "@/components/DialogueHistoryModal";
import type { Beat, BeatChoice, Orientation } from "@infiplot/types";
export type Phase =
@@ -174,6 +178,7 @@ export function PlayCanvas({
orientation = "landscape",
aboveCanvas,
aboveCanvasLeft,
dialogueHistory = [],
}: {
imageUrl: string | null;
audioSrc: string | null;
@@ -191,9 +196,11 @@ export function PlayCanvas({
aboveCanvas?: ReactNode;
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
aboveCanvasLeft?: ReactNode;
dialogueHistory?: DialogueHistoryItem[];
}) {
const imgRef = useRef<HTMLImageElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
undefined,
);
@@ -404,11 +411,19 @@ export function PlayCanvas({
: undefined
}
>
{historyOpen && (
<DialogueHistoryModal
items={dialogueHistory}
portrait={portrait}
onClose={() => setHistoryOpen(false)}
/>
)}
{choices.length > 0 && (
<div
className={`pointer-events-auto px-[3%] pb-[1.5%] flex items-stretch ${
portrait
? "flex-col gap-2 max-h-[45dvh] overflow-y-auto"
? "vn-scrollbar flex-col gap-2 max-h-[45dvh] overflow-y-auto"
: "gap-[1.5%]"
}`}
>
@@ -487,13 +502,26 @@ export function PlayCanvas({
{typingDone && beat.next.type === "continue" && (
<span
className="absolute bottom-[6px] right-[10px] text-[10px] animate-slow-pulse"
className="absolute bottom-[6px] right-[42px] text-[10px] animate-slow-pulse"
style={{ color: "rgba(195,155,75,0.7)" }}
aria-hidden
>
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setHistoryOpen(true);
}}
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
aria-label="打开剧情回溯"
title="剧情回溯"
>
<i className="fa-solid fa-clock-rotate-left text-[12px]" />
</button>
</div>
)}
</div>