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>
This commit is contained in:
yuanzonghao
2026-05-09 13:29:58 +08:00
commit cbd95bbea2
45 changed files with 1855 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function CustomForm() {
const router = useRouter();
const [worldSetting, setWorldSetting] = useState("");
const [styleGuide, setStyleGuide] = useState("");
const [submitting, setSubmitting] = useState(false);
const canSubmit =
worldSetting.trim().length > 10 &&
styleGuide.trim().length > 5 &&
!submitting;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
sessionStorage.setItem(
"dada:custom",
JSON.stringify({ worldSetting, styleGuide }),
);
router.push("/play?custom=1");
}
return (
<form onSubmit={handleSubmit} className="space-y-12 animate-fade-in">
<div>
<label className="flex items-baseline justify-between mb-4">
<span className="text-[10px] smallcaps text-clay-700 font-medium">
<span className="text-clay-400 mr-2 font-serif italic not-italic font-normal">
</span>
World ·
</span>
<span className="text-[10px] text-clay-400 num">
{worldSetting.length}
</span>
</label>
<textarea
value={worldSetting}
onChange={(e) => setWorldSetting(e.target.value)}
rows={6}
placeholder="例:1990 年代末的中国南方县城。主角是高三转学生,在多雨的六月遇到一个总在天台读诗的同学。剧情慢热、含蓄、带点伤感⋯"
className="w-full bg-transparent border-0 border-b border-clay-900/20 px-0 py-3 text-clay-900 font-serif text-lg leading-[1.7] focus:outline-none focus:border-clay-700 transition-colors resize-none placeholder:font-serif placeholder:italic placeholder:text-base placeholder:leading-[1.7]"
/>
</div>
<div>
<label className="flex items-baseline justify-between mb-4">
<span className="text-[10px] smallcaps text-clay-700 font-medium">
<span className="text-clay-400 mr-2 font-serif italic not-italic font-normal">
</span>
Style ·
</span>
<span className="text-[10px] text-clay-400 num">
{styleGuide.length}
</span>
</label>
<textarea
value={styleGuide}
onChange={(e) => setStyleGuide(e.target.value)}
rows={4}
placeholder="例:Soft watercolor, warm afternoon light, anime visual novel style, classic dialogue panel⋯"
className="w-full bg-transparent border-0 border-b border-clay-900/20 px-0 py-3 text-clay-900 font-serif text-lg leading-[1.7] focus:outline-none focus:border-clay-700 transition-colors resize-none placeholder:font-serif placeholder:italic placeholder:text-base placeholder:leading-[1.7]"
/>
</div>
<div className="pt-6 flex items-center justify-between">
<span className="text-[10px] smallcaps text-clay-500">
{submitting
? "Summoning the first frame…"
: canSubmit
? "Ready when you are"
: "Two paragraphs · enough to begin"}
</span>
<button
type="submit"
disabled={!canSubmit}
className="group flex items-center gap-3 text-[10px] smallcaps text-clay-900 disabled:text-clay-300 disabled:cursor-not-allowed enabled:hover:text-ember-500 transition-colors duration-300"
>
Begin
<span className="w-10 h-px bg-current transition-all duration-300 group-enabled:group-hover:w-16" />
<i className="fa-solid fa-arrow-right text-[9px]" />
</button>
</div>
</form>
);
}
+106
View File
@@ -0,0 +1,106 @@
"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>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import type { Preset } from "@/lib/presets";
export function PresetCard({
preset,
ordinal,
}: {
preset: Preset;
ordinal: string;
}) {
const router = useRouter();
return (
<button
onClick={() => router.push(`/play?preset=${preset.id}`)}
className="group block w-full py-10 md:py-12 border-t border-clay-900/10 hover:border-clay-900/35 transition-[border-color,padding] duration-500 text-left"
>
<div className="flex items-baseline gap-6 md:gap-10">
<span className="font-serif italic text-2xl md:text-3xl text-clay-400 group-hover:text-clay-700 transition-colors duration-500 w-8 shrink-0">
{ordinal}
</span>
<div className="flex-1 min-w-0">
<h3 className="font-serif text-3xl md:text-4xl text-clay-900 leading-tight mb-2.5">
{preset.title}
</h3>
<p className="text-sm text-clay-600 leading-relaxed max-w-md">
{preset.blurb}
</p>
</div>
<span className="hidden md:flex items-center gap-3 text-[10px] tracking-[0.4em] text-clay-400 group-hover:text-ember-500 transition-colors duration-500 shrink-0 self-center">
ENTER
<span className="w-7 h-px bg-current transition-all duration-500 group-hover:w-12" />
</span>
</div>
</button>
);
}