"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { Beat, BeatChoice } from "@yume/types"; export type Phase = | "loading-first" // first scene not yet rendered | "ready" // current beat is interactive | "vision-thinking" // background click → waiting on vision verdict | "inserting-beat" // vision-driven beat being generated | "transitioning"; // changing scenes (cache miss or speculative wait) const SHADOW = "0 1px 0 rgba(45,24,16,0.05), 0 36px 64px -28px rgba(45,24,16,0.25), 0 8px 18px -6px rgba(45,24,16,0.10)"; // ── Typewriter hook ──────────────────────────────────────────────────── // Returns the progressively-revealed text, a `done` flag, and a `skip()` that // instantly completes the current text. Reset is keyed by `resetKey` (the beat // id) rather than the text, so a new beat whose line happens to match the // previous one still replays from scratch. `done` is derived synchronously // (not from a post-paint effect) so a stale "done" frame never paints. function useTypewriter( text: string, resetKey: string, speed = 28, ): { shown: string; done: boolean; skip: () => void } { const [displayed, setDisplayed] = useState(""); const [prevKey, setPrevKey] = useState(resetKey); const timer = useRef | null>(null); // Render-phase reset (React "adjust state on prop change" pattern): when the // beat changes, drop the old progress before this render commits. if (resetKey !== prevKey) { setPrevKey(resetKey); setDisplayed(""); } useEffect(() => { if (!text) return; let i = 0; timer.current = setInterval(() => { i += 1; setDisplayed(text.slice(0, i)); if (i >= text.length && timer.current) { clearInterval(timer.current); timer.current = null; } }, speed); return () => { if (timer.current) clearInterval(timer.current); timer.current = null; }; }, [resetKey, text, speed]); const skip = useCallback(() => { if (timer.current) { clearInterval(timer.current); timer.current = null; } setDisplayed(text); }, [text]); // During the throwaway render where the beat just changed, `displayed` still // holds the previous beat's text — coerce it to empty so nothing stale shows. const shown = resetKey === prevKey ? displayed : ""; const done = text.length === 0 || shown.length >= text.length; return { shown, done, skip }; } // ── Choice button ────────────────────────────────────────────────────── function ChoiceButton({ index, label, disabled, onClick, }: { index: number; label: string; disabled: boolean; onClick: () => void; }) { return ( ); } // ── Main component ───────────────────────────────────────────────────── export function PlayCanvas({ imageBase64, phase, beat, pendingClick, onBackgroundClick, onAdvance, onSelectChoice, fullViewport = false, }: { imageBase64: string | null; phase: Phase; beat: Beat | null; pendingClick: { x: number; y: number } | null; onBackgroundClick: (click: { x: number; y: number }) => void; onAdvance: () => void; onSelectChoice: (choice: BeatChoice) => void; fullViewport?: boolean; }) { const imgRef = useRef(null); const [dims, setDims] = useState<{ w: number; h: number } | null>(null); const isChoiceBeat = beat?.next.type === "choice"; const choices: BeatChoice[] = isChoiceBeat ? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices : []; const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? ""; const { shown: typedBody, done: typingDone, skip: skipTypewriter } = useTypewriter(displayBody, beat?.id ?? "", 30); function handleImageClick(e: React.MouseEvent) { if (phase !== "ready" || !imgRef.current || !beat) return; const rect = imgRef.current.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = (e.clientY - rect.top) / rect.height; // If the typewriter is still printing, a click completes it instantly // (standard VN affordance) — the page never sees this click. if (!typingDone) { skipTypewriter(); return; } // For continue-type beats, image click advances; for choice beats, // image click goes through vision (treat as freeform action). if (beat.next.type === "continue") { onAdvance(); return; } onBackgroundClick({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)), }); } const interactive = phase === "ready" && !!imageBase64; const dimmed = phase === "transitioning"; const sizeStyle = fullViewport ? { maxWidth: "100vw", maxHeight: "100dvh" } : { maxWidth: "96vw", maxHeight: "calc(100dvh - 200px)" }; const placeholderWidth = fullViewport ? "min(100vw, calc(100dvh * 16 / 9))" : "min(96vw, calc((100dvh - 200px) * 16 / 9))"; const footerHint = phase === "ready" ? isChoiceBeat ? "选 · 择 · 一 · 项" : "点 · 击 · 推 · 进" : "···"; return (
{imageBase64 ? (
{/* Background image */} Generated scene { const img = e.currentTarget; setDims({ w: img.naturalWidth, h: img.naturalHeight }); }} draggable={false} className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${ interactive ? "cursor-pointer" : "cursor-wait" } ${dimmed ? "opacity-40" : "opacity-100"}`} style={sizeStyle} /> {!fullViewport && (
)} {beat && (
{choices.length > 0 && (
{choices.map((choice, i) => ( onSelectChoice(choice)} /> ))}
)} {(beat.narration || beat.line) && (
{beat.speaker && (

{beat.speaker}

)}

{typedBody} {beat.speaker && beat.narration && ( {beat.narration} )}

{typingDone && beat.next.type === "continue" && ( )}
)}
)} {(phase === "transitioning" || phase === "inserting-beat") && (

{phase === "transitioning" ? "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕" : "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么"}

)} {pendingClick && ( <>
)}
) : (

正 · 在 · 绘 · 制 · 第 · 一 · 幕

)} {!fullViewport && (
{dims ? `${dims.w} × ${dims.h} · png` : "—"} {footerHint}
)}
); }