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. 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. Comment only non-obvious sequencing, provider quirks, fallback behavior, or architectural invariants.
## Configuration & Providers ## Configuration & Providers
+36
View File
@@ -52,6 +52,42 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.32em; 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 { @keyframes infiplot-ripple {
+74 -1
View File
@@ -11,7 +11,11 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } 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 { TtsKeyModal } from "@/components/TtsKeyModal";
import { annotateClick } from "@/lib/annotateClient"; import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
@@ -262,6 +266,63 @@ type ScenePathStep = {
exit: { choiceId: string; label: string; nextSceneSeed: string }; 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 { function pathKey(steps: ScenePathStep[]): string {
return steps.map((s) => s.exit.choiceId).join("/"); return steps.map((s) => s.exit.choiceId).join("/");
} }
@@ -549,6 +610,16 @@ function PlayInner() {
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null; return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
}, [currentScene, currentBeatId]); }, [currentScene, currentBeatId]);
const dialogueHistory = useMemo<DialogueHistoryItem[]>(
() =>
buildDialogueHistory(
session,
currentScene?.id,
visitedBeatsRef.current,
),
[session, currentScene?.id, currentBeatId],
);
const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null; const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null;
useEffect(() => { useEffect(() => {
@@ -1369,6 +1440,7 @@ function PlayInner() {
onSelectChoice={onSelectChoice} onSelectChoice={onSelectChoice}
orientation={orientation} orientation={orientation}
fullViewport fullViewport
dialogueHistory={dialogueHistory}
/> />
{orientation === "portrait" && ( {orientation === "portrait" && (
<div <div
@@ -1442,6 +1514,7 @@ function PlayInner() {
onAdvance={onAdvance} onAdvance={onAdvance}
onSelectChoice={onSelectChoice} onSelectChoice={onSelectChoice}
orientation={orientation} orientation={orientation}
dialogueHistory={dialogueHistory}
aboveCanvas={ aboveCanvas={
<button <button
type="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"; "use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
DialogueHistoryModal,
type DialogueHistoryItem,
} from "@/components/DialogueHistoryModal";
import type { Beat, BeatChoice, Orientation } from "@infiplot/types"; import type { Beat, BeatChoice, Orientation } from "@infiplot/types";
export type Phase = export type Phase =
@@ -174,6 +178,7 @@ export function PlayCanvas({
orientation = "landscape", orientation = "landscape",
aboveCanvas, aboveCanvas,
aboveCanvasLeft, aboveCanvasLeft,
dialogueHistory = [],
}: { }: {
imageUrl: string | null; imageUrl: string | null;
audioSrc: string | null; audioSrc: string | null;
@@ -191,9 +196,11 @@ export function PlayCanvas({
aboveCanvas?: ReactNode; aboveCanvas?: ReactNode;
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。 // 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
aboveCanvasLeft?: ReactNode; aboveCanvasLeft?: ReactNode;
dialogueHistory?: DialogueHistoryItem[];
}) { }) {
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>( const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
undefined, undefined,
); );
@@ -404,11 +411,19 @@ export function PlayCanvas({
: undefined : undefined
} }
> >
{historyOpen && (
<DialogueHistoryModal
items={dialogueHistory}
portrait={portrait}
onClose={() => setHistoryOpen(false)}
/>
)}
{choices.length > 0 && ( {choices.length > 0 && (
<div <div
className={`pointer-events-auto px-[3%] pb-[1.5%] flex items-stretch ${ className={`pointer-events-auto px-[3%] pb-[1.5%] flex items-stretch ${
portrait 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%]" : "gap-[1.5%]"
}`} }`}
> >
@@ -487,13 +502,26 @@ export function PlayCanvas({
{typingDone && beat.next.type === "continue" && ( {typingDone && beat.next.type === "continue" && (
<span <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)" }} style={{ color: "rgba(195,155,75,0.7)" }}
aria-hidden aria-hidden
> >
</span> </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>
)} )}
</div> </div>