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.
|
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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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";
|
"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>
|
||||||
|
|||||||
Reference in New Issue
Block a user