Files
infiplot-web/apps/web/components/PlayCanvas.tsx
T
Zonghao Yuan d1f13d51a3 feat: scene/beat architecture — decouple dialogue from image generation (#2)
Replace the one-image-per-interaction model with scenes that hold multiple
dialogue beats. The image regenerates only on scene-change actions; tapping
through beats and in-scene choices are instant and zero-network.

Squashed from #2:
- feat: scene/beat architecture — decouple dialogue from image generation
- fix: harden LLM-output parsing, prefetch lifecycle, and typewriter (PR review)
- fix: dedupe beat ids; fallback narration on empty insert-beat (PR review #2)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-28 15:20:12 +08:00

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<ReturnType<typeof setInterval> | 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 (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="group relative flex-1 min-w-0 px-4 py-3 text-left transition-all duration-200
disabled:opacity-50 disabled:cursor-wait"
style={{
background: "rgba(20, 14, 8, 0.68)",
border: "1.5px solid rgba(180, 140, 80, 0.65)",
borderRadius: "6px",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
boxShadow: "0 2px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(200,165,90,0.12)",
}}
>
<span
className="absolute inset-0 rounded-[5px] opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
style={{
background: "rgba(180,140,60,0.10)",
border: "1.5px solid rgba(200,165,90,0.85)",
}}
/>
<span className="relative flex items-baseline gap-2">
<span
className="shrink-0 font-serif text-[11px] num"
style={{ color: "rgba(195,155,75,0.9)" }}
>
{index + 1}.
</span>
<span
className="font-serif text-[13px] md:text-[14px] leading-snug"
style={{ color: "rgba(245,235,210,0.95)" }}
>
{label}
</span>
</span>
</button>
);
}
// ── 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<HTMLImageElement>(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<HTMLImageElement>) {
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 (
<div
className={`flex flex-col items-center ${fullViewport ? "w-full h-full justify-center" : "w-full"}`}
>
{imageBase64 ? (
<div
className="relative inline-block"
style={{ boxShadow: fullViewport ? "none" : SHADOW }}
>
{/* Background image */}
<img
key={imageBase64.slice(-48)}
ref={imgRef}
src={`data:image/png;base64,${imageBase64}`}
alt="Generated scene"
onClick={handleImageClick}
onLoad={(e) => {
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 && (
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
)}
{beat && (
<div className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none">
{choices.length > 0 && (
<div className="pointer-events-auto px-[3%] pb-[1.5%] flex gap-[1.5%] items-stretch">
{choices.map((choice, i) => (
<ChoiceButton
key={choice.id}
index={i}
label={choice.label}
disabled={phase !== "ready"}
onClick={() => onSelectChoice(choice)}
/>
))}
</div>
)}
{(beat.narration || beat.line) && (
<div
className="pointer-events-none mx-[2%] mb-[2%] px-[3%] py-[2.2%] relative"
style={{
background: "rgba(14, 10, 6, 0.72)",
border: "1.5px solid rgba(175, 138, 72, 0.60)",
borderRadius: "6px",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
boxShadow:
"0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(200,165,90,0.10)",
}}
>
<span
className="absolute top-[6px] left-[8px] text-[10px] opacity-40 pointer-events-none"
style={{ color: "rgba(195,155,75,1)" }}
aria-hidden
>
</span>
<span
className="absolute top-[6px] right-[8px] text-[10px] opacity-40 pointer-events-none"
style={{ color: "rgba(195,155,75,1)" }}
aria-hidden
>
</span>
{beat.speaker && (
<p
className="font-serif text-[11px] md:text-[12px] smallcaps mb-[0.6em]"
style={{ color: "rgba(205,165,90,0.92)" }}
>
{beat.speaker}
</p>
)}
<p
className="font-serif leading-[1.85] text-[13px] md:text-[15px]"
style={{ color: "rgba(245,235,210,0.95)" }}
>
{typedBody}
{beat.speaker && beat.narration && (
<span
className={`block mt-[0.5em] italic text-[12px] md:text-[13px] transition-opacity duration-300 ${
typingDone ? "opacity-100" : "opacity-0"
}`}
style={{ color: "rgba(200,185,155,0.78)" }}
aria-hidden={!typingDone}
>
{beat.narration}
</span>
)}
</p>
{typingDone && beat.next.type === "continue" && (
<span
className="absolute bottom-[6px] right-[10px] text-[10px] animate-slow-pulse"
style={{ color: "rgba(195,155,75,0.7)" }}
aria-hidden
>
</span>
)}
</div>
)}
</div>
)}
{(phase === "transitioning" || phase === "inserting-beat") && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-[10px] smallcaps text-cream-50/70 animate-slow-pulse">
{phase === "transitioning"
? "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕"
: "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么"}
</p>
</div>
)}
{pendingClick && (
<>
<div
className="absolute rounded-full border border-ember-500 pointer-events-none"
style={{
left: `${pendingClick.x * 100}%`,
top: `${pendingClick.y * 100}%`,
transform: "translate(-50%, -50%)",
width: 30,
height: 30,
animation:
"yume-ripple 1.6s cubic-bezier(0.16,1,0.3,1) infinite",
}}
/>
<div
className="absolute rounded-full pointer-events-none"
style={{
left: `${pendingClick.x * 100}%`,
top: `${pendingClick.y * 100}%`,
transform: "translate(-50%, -50%)",
width: 11,
height: 11,
background: "#D97A2E",
boxShadow:
"0 0 0 3px rgba(251,247,240,0.95), 0 0 14px rgba(217,122,46,0.55)",
}}
/>
</>
)}
</div>
) : (
<div
className="relative aspect-video bg-cream-200 flex flex-col items-center justify-center gap-4"
style={{
width: placeholderWidth,
boxShadow: fullViewport ? "none" : SHADOW,
}}
>
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
· · · · · ·
</p>
</div>
)}
{!fullViewport && (
<div
className="flex items-center justify-between mt-3 px-1 w-full"
style={{ maxWidth: "96vw" }}
>
<span className="text-[9px] smallcaps text-clay-400 num">
{dims ? `${dims.w} × ${dims.h} · png` : "—"}
</span>
<span className="text-[9px] smallcaps text-clay-400">{footerHint}</span>
</div>
)}
</div>
);
}