"use client"; import { useEffect, useRef, useState } from "react"; import type { StoryFrame } from "@yume/types"; export type Phase = "loading-first" | "ready" | "interacting"; 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 ──────────────────────────────────────────────────── function useTypewriter(text: string, speed = 28): string { const [displayed, setDisplayed] = useState(""); const textRef = useRef(text); useEffect(() => { // Reset immediately when the text changes setDisplayed(""); textRef.current = text; if (!text) return; let i = 0; const id = setInterval(() => { i += 1; setDisplayed(text.slice(0, i)); if (i >= text.length) clearInterval(id); }, speed); return () => clearInterval(id); }, [text, speed]); return displayed; } // ── Choice button ────────────────────────────────────────────────────── function ChoiceButton({ index, label, disabled, onClick, }: { index: number; label: string; disabled: boolean; onClick: () => void; }) { return ( ); } // ── Main component ───────────────────────────────────────────────────── export function PlayCanvas({ imageBase64, phase, frame, pendingClick, onClick, onSelectChoice, fullViewport = false, }: { imageBase64: string | null; phase: Phase; frame: StoryFrame | null; pendingClick: { x: number; y: number } | null; onClick: (click: { x: number; y: number }) => void; onSelectChoice?: (choiceId: string, label: string) => void; fullViewport?: boolean; }) { const imgRef = useRef(null); const [dims, setDims] = useState<{ w: number; h: number } | null>(null); const choices = frame?.uiElements.filter((e) => e.kind === "choice") ?? []; const dialogueText = frame ? [frame.speaker ? `${frame.speaker}:${frame.line ?? ""}` : frame.line, frame.narration] .filter(Boolean) .join("\n") : ""; const narrationOnly = !frame?.speaker && !frame?.line && !!frame?.narration; const displayBody = frame?.speaker ? frame.line ?? "" : frame?.narration ?? ""; const typedBody = useTypewriter(displayBody, 30); function handleClick(e: React.MouseEvent) { if (phase !== "ready" || !imgRef.current) return; const rect = imgRef.current.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = (e.clientY - rect.top) / rect.height; onClick({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)), }); } const interactive = phase === "ready" && !!imageBase64; const dimmed = phase === "interacting"; 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))"; return (
{imageBase64 ? (
{/* ── Background image ── */} Generated frame { 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} /> {/* ── Top/bottom gradient vignette ── */} {!fullViewport && ( <>
)} {/* ══════════════════════════════════════════════════════════ PREFAB UI OVERLAY — rendered on top of image ══════════════════════════════════════════════════════════ */} {frame && (
{/* ── Choices row ── */} {choices.length > 0 && (
{choices.map((choice, i) => ( onSelectChoice?.(choice.id, choice.label)} /> ))}
)} {/* ── Dialogue / narration box ── */} {(frame.narration || frame.line) && (
{/* Inner golden corner decoration */} {/* Speaker name tag */} {frame.speaker && (

{frame.speaker}

)} {/* Main text */}

{typedBody} {/* Narration only — also show secondary line */} {frame.speaker && frame.narration && ( {frame.narration} )}

{/* Scroll hint ▼ */}
)}
)} {/* Loading/interacting dim overlay */} {phase === "interacting" && (

AI · 正 · 在 · 描 · 画 · 下 · 一 · 刻

)} {/* Click ripple indicator */} {pendingClick && ( <>
)}
) : (

正 · 在 · 绘 · 制 · 第 · 一 · 帧

)} {!fullViewport && (
{dims ? `${dims.w} × ${dims.h} · png` : "—"} {phase === "ready" ? (choices.length > 0 ? "选 · 择 · 一 · 项" : "任 · 意 · 点 · 击") : "···"}
)}
); }