feat(play): add history dialog
Signed-off-by: baizhi958216 <1475289190@qq.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user