Files
infiplot-web/apps/web/components/PlayCanvas.tsx
T
yuanzonghao cbd95bbea2 Initial commit: AI-driven visual novel scaffold
- Monorepo (pnpm workspace): apps/web + packages/{types,ai-client,engine}
- Next.js 16 web app with three-stage AI orchestration
- Three independently configurable providers: text LLM, image generator, vision model
- Warm minimalist editorial UI design
- One-click Vercel deploy ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:29:58 +08:00

107 lines
3.6 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 { useRef } from "react";
export type Phase = "loading-first" | "ready" | "interacting";
export function PlayCanvas({
imageBase64,
phase,
pendingClick,
onClick,
}: {
imageBase64: string | null;
phase: Phase;
pendingClick: { x: number; y: number } | null;
onClick: (click: { x: number; y: number }) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
if (phase !== "ready" || !ref.current || !imageBase64) return;
const rect = ref.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";
return (
<div className="w-full max-w-[440px] mx-auto">
<div
ref={ref}
onClick={handleClick}
className={`relative aspect-[2/3] w-full overflow-hidden bg-cream-200 select-none ${interactive ? "cursor-pointer" : "cursor-wait"}`}
style={{
boxShadow:
"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)",
}}
>
{imageBase64 ? (
<img
key={imageBase64.slice(-48)}
src={`data:image/png;base64,${imageBase64}`}
alt="Generated frame"
className={`absolute inset-0 w-full h-full object-cover animate-fade-in transition-opacity duration-700 ease-out ${dimmed ? "opacity-30" : "opacity-100"}`}
draggable={false}
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4">
<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">
Painting · the · first · frame
</p>
</div>
)}
<div className="absolute inset-x-0 top-0 h-12 bg-gradient-to-b from-clay-900/15 to-transparent pointer-events-none" />
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-clay-900/15 to-transparent pointer-events-none" />
{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:
"dada-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="flex items-center justify-between mt-3 px-1">
<span className="text-[9px] smallcaps text-clay-400 num">
1024 × 1536 · png
</span>
<span className="text-[9px] smallcaps text-clay-400">
{phase === "ready" ? "Tap · anywhere" : "···"}
</span>
</div>
</div>
);
}