diff --git a/AGENTS.md b/AGENTS.md index aa4f26b..397c77b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/globals.css b/app/globals.css index 5051ba2..9cb7d82 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 { diff --git a/app/play/page.tsx b/app/play/page.tsx index 528da77..225ffc7 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -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( + () => + 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" && (
void; +}) { + const listRef = useRef(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 ( +
+
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="剧情回溯" + > +
+
+ + 剧 · 情 · 回 · 溯 +
+ +
+ +
+ {items.length === 0 ? ( +

+ 暂无历史。 +

+ ) : ( +
+ {items.map((item) => ( +
+
+ + 第 {String(item.sceneIndex).padStart(3, "0")} 幕 + + {item.speaker && ( + + {item.speaker} + + )} +
+ {item.body && ( +

+ {item.body} +

+ )} + {item.narration && ( +

+ {item.narration} +

+ )} + {item.selectedChoice && ( +

+ + 选择 + + {item.selectedChoice} +

+ )} + {item.freeformAction && ( +

+ + 行动 + + {item.freeformAction} +

+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index 5ee2ced..db23245 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -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(null); const audioRef = useRef(null); + const [historyOpen, setHistoryOpen] = useState(false); const [audioDurationMs, setAudioDurationMs] = useState( undefined, ); @@ -404,11 +411,19 @@ export function PlayCanvas({ : undefined } > + {historyOpen && ( + setHistoryOpen(false)} + /> + )} + {choices.length > 0 && (
@@ -487,13 +502,26 @@ export function PlayCanvas({ {typingDone && beat.next.type === "continue" && ( )} + +
)}