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
+32
View File
@@ -0,0 +1,32 @@
import { takeTurn } from "@dada/engine";
import type { InteractRequest } from "@dada/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST(req: Request) {
let body: InteractRequest;
try {
body = (await req.json()) as InteractRequest;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.session || !body.prevImageBase64 || !body.click) {
return NextResponse.json(
{ error: "session, prevImageBase64, click are required" },
{ status: 400 },
);
}
try {
const config = loadEngineConfig();
const result = await takeTurn(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { startSession } from "@dada/engine";
import type { StartRequest } from "@dada/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST(req: Request) {
let body: StartRequest;
try {
body = (await req.json()) as StartRequest;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.worldSetting?.trim() || !body.styleGuide?.trim()) {
return NextResponse.json(
{ error: "worldSetting and styleGuide are required" },
{ status: 400 },
);
}
try {
const config = loadEngineConfig();
const result = await startSession(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+68
View File
@@ -0,0 +1,68 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-feature-settings: "ss01", "kern", "liga";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-image:
radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px),
radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px);
background-size: 28px 28px, 38px 38px;
background-position: 0 0, 14px 19px;
}
::selection {
background-color: rgb(217 122 46 / 0.28);
color: #2d1810;
}
textarea::placeholder {
color: rgb(168 105 59 / 0.45);
}
}
@layer utilities {
.hairline {
background-image: linear-gradient(
to right,
transparent,
rgba(45, 24, 16, 0.18) 18%,
rgba(45, 24, 16, 0.18) 82%,
transparent
);
height: 1px;
}
.hairline-full {
height: 1px;
background: rgba(45, 24, 16, 0.14);
}
.num {
font-variant-numeric: tabular-nums lining-nums;
}
.smallcaps {
text-transform: uppercase;
letter-spacing: 0.32em;
}
}
@keyframes dada-ripple {
0% {
width: 14px;
height: 14px;
opacity: 0.95;
}
100% {
width: 110px;
height: 110px;
opacity: 0;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Dada — AI Visual Novel",
description:
"An open-source visual novel where every frame is generated by AI.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@300;400;500&display=swap"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
</head>
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen">
{children}
</body>
</html>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { CustomForm } from "@/components/CustomForm";
export default function NewPage() {
return (
<div className="min-h-screen flex flex-col">
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
<Link
href="/"
className="text-[10px] smallcaps text-clay-700 hover:text-clay-900 transition-colors flex items-center gap-2"
>
<i className="fa-solid fa-arrow-left text-[9px]" />
Dada
</Link>
<span className="text-[10px] smallcaps text-clay-500">
Compose · a · world
</span>
</header>
<section className="px-6 md:px-16 pt-20 md:pt-32 pb-20 md:pb-24 flex-1">
<div className="grid grid-cols-12 gap-8 md:gap-16 max-w-6xl">
<div className="col-span-12 md:col-span-4 animate-fade-in">
<p className="text-[10px] smallcaps text-clay-500 mb-6">
· Untitled
</p>
<h1 className="font-serif text-[44px] md:text-[64px] text-clay-900 leading-[0.96] mb-8">
Write
<br />
<em className="italic text-clay-600">two</em>
<br />
paragraphs.
</h1>
<div className="hairline w-12 mb-6" />
<p className="font-serif text-base text-clay-700 leading-[1.7]">
The first sketches the world your story unfolds in. The second
describes how the world should look its medium, its mood, its
grain.
</p>
<p className="font-serif italic text-sm text-clay-500 mt-5 leading-relaxed">
Both fields accept any language. Specificity rewards specificity.
</p>
</div>
<div className="col-span-12 md:col-span-7 md:col-start-6">
<CustomForm />
</div>
</div>
</section>
<footer className="px-6 md:px-16 pb-8">
<div className="hairline-full w-full mb-4" />
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
<span>MIT · MMXXVI</span>
<span className="num"> · </span>
</div>
</footer>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import Link from "next/link";
import { PRESETS } from "@/lib/presets";
import { PresetCard } from "@/components/PresetCard";
const ORDINALS = ["", "Ⅱ", "Ⅲ", "Ⅳ"];
export default function LandingPage() {
return (
<div className="min-h-screen flex flex-col">
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-[10px] smallcaps text-clay-700 font-medium">
Dada
</span>
<span className="hairline w-10 hidden md:block" />
<span className="text-[10px] smallcaps text-clay-500 hidden md:block">
Frame · by · Frame
</span>
</div>
<nav className="flex items-center gap-5 text-[10px] smallcaps text-clay-600">
<a
href="https://github.com"
className="hover:text-clay-900 transition-colors"
>
GitHub
</a>
<span className="text-clay-300">·</span>
<a href="#about" className="hover:text-clay-900 transition-colors">
About
</a>
</nav>
</header>
<section className="px-6 md:px-16 pt-20 md:pt-36 pb-20 md:pb-28">
<div className="grid grid-cols-12 gap-8">
<div className="col-span-12 md:col-span-7 animate-fade-in">
<p className="text-[10px] smallcaps text-clay-500 mb-8">
An open-source experiment · MMXXVI
</p>
<h1 className="font-serif font-light text-[56px] md:text-[104px] leading-[0.94] text-clay-900 tracking-tight">
Every{" "}
<em className="italic font-light text-clay-600">frame</em>
<br />
is painted on
<br />
<span className="text-ember-500 italic font-light">demand.</span>
</h1>
<p className="mt-10 md:mt-14 max-w-md font-serif text-lg md:text-xl text-clay-700 leading-[1.65]">
Dada is a visual novel where the <em>entire</em> interface scene,
dialogue, choices is rendered by an AI, one frame at a time. You
click. It paints. The story unfolds.
</p>
</div>
<aside className="col-span-12 md:col-span-4 md:col-start-9 mt-8 md:mt-0 flex md:items-end">
<div className="space-y-3">
<div className="hairline w-12" />
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
&ldquo;It is impossible to step into the same river twice.
</p>
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
It is impossible to play the same Dada twice.&rdquo;
</p>
<p className="text-[10px] smallcaps text-clay-500 pt-2">
README · v0.1
</p>
</div>
</aside>
</div>
</section>
<div className="px-6 md:px-16">
<div className="hairline-full w-full" />
</div>
<section className="px-6 md:px-16 pt-14 md:pt-20 pb-16 md:pb-24">
<div className="flex items-baseline justify-between mb-8 md:mb-10">
<h2 className="text-[10px] smallcaps text-clay-700 font-medium">
Four Doors
</h2>
<p className="text-[10px] smallcaps text-clay-500 hidden md:block">
Choose a world · or compose your own
</p>
</div>
<div className="grid grid-cols-1">
{PRESETS.map((p, i) => (
<PresetCard key={p.id} preset={p} ordinal={ORDINALS[i]!} />
))}
<Link
href="/new"
className="group block w-full py-10 md:py-12 border-t border-b border-clay-900/10 hover:border-clay-900/35 transition-[border-color] duration-500"
>
<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">
{ORDINALS[3]}
</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">
Untitled
</h3>
<p className="text-sm text-clay-600 leading-relaxed max-w-md">
Bring your own world. Describe it in your own words.
</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">
COMPOSE
<span className="w-7 h-px bg-current transition-all duration-500 group-hover:w-12" />
</span>
</div>
</Link>
</div>
</section>
<section
id="about"
className="px-6 md:px-16 pb-20 md:pb-28 grid grid-cols-12 gap-8"
>
<div className="col-span-12 md:col-span-3">
<p className="text-[10px] smallcaps text-clay-500 mb-3">
Colophon · I
</p>
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
A small open-source experiment in generative narrative. Self-host on
Vercel in a single click.
</p>
</div>
<div className="col-span-12 md:col-span-3 md:col-start-5">
<p className="text-[10px] smallcaps text-clay-500 mb-3">
Colophon · II
</p>
<ul className="font-serif text-clay-700 text-base leading-relaxed space-y-1">
<li>Story · large language model</li>
<li>Image · generative renderer</li>
<li>Click · vision interpreter</li>
</ul>
</div>
<div className="col-span-12 md:col-span-3 md:col-start-9">
<p className="text-[10px] smallcaps text-clay-500 mb-3">
Colophon · III
</p>
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
All three are configured separately bring any OpenAI-compatible
endpoint.
</p>
</div>
</section>
<footer className="px-6 md:px-16 pb-10 mt-auto">
<div className="hairline-full w-full mb-5" />
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
<span>MIT · MMXXVI</span>
<span className="num"> · </span>
</div>
</footer>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
import { PRESETS } from "@/lib/presets";
import type {
ClickIntent,
InteractResponse,
Session,
StartResponse,
StoryFrame,
} from "@dada/types";
function PlayInner() {
const router = useRouter();
const params = useSearchParams();
const [phase, setPhase] = useState<Phase>("loading-first");
const [session, setSession] = useState<Session | null>(null);
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [frame, setFrame] = useState<StoryFrame | null>(null);
const [intent, setIntent] = useState<ClickIntent | null>(null);
const [pendingClick, setPendingClick] = useState<{
x: number;
y: number;
} | null>(null);
const [turnNum, setTurnNum] = useState(0);
const [error, setError] = useState<string | null>(null);
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
let payload: { worldSetting: string; styleGuide: string } | null = null;
const presetId = params.get("preset");
if (presetId) {
const p = PRESETS.find((x) => x.id === presetId);
if (p) {
payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
}
} else if (params.get("custom") === "1") {
const stored = sessionStorage.getItem("dada:custom");
if (stored) {
try {
payload = JSON.parse(stored);
} catch {
payload = null;
}
}
}
if (!payload) {
router.replace("/");
return;
}
const finalPayload = payload;
fetch("/api/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(finalPayload),
})
.then(async (r) => {
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? r.statusText);
}
return r.json() as Promise<StartResponse>;
})
.then((data) => {
setSession({
id: data.sessionId,
createdAt: Date.now(),
worldSetting: finalPayload.worldSetting,
styleGuide: finalPayload.styleGuide,
history: [{ frame: data.frame }],
});
setFrame(data.frame);
setImageBase64(data.imageBase64);
setPhase("ready");
setTurnNum(1);
})
.catch((e) => setError(String(e)));
}, [params, router]);
async function handleClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !imageBase64) return;
setPhase("interacting");
setPendingClick(click);
setIntent(null);
try {
const res = await fetch("/api/interact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session,
prevImageBase64: imageBase64,
click,
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
const data = (await res.json()) as InteractResponse;
const updatedHistory = [
...data.session.history,
{ frame: data.frame },
];
setSession({ ...data.session, history: updatedHistory });
setFrame(data.frame);
setImageBase64(data.imageBase64);
setIntent(data.intent);
setPendingClick(null);
setTurnNum((t) => t + 1);
setPhase("ready");
} catch (e) {
setError(String(e));
setPendingClick(null);
setPhase("ready");
}
}
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<div className="max-w-md text-center animate-fade-in">
<p className="text-[10px] smallcaps text-clay-500 mb-6">
An · error · occurred
</p>
<p className="font-serif italic text-clay-900 text-lg leading-[1.7] mb-10">
{error}
</p>
<Link
href="/"
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
>
<i className="fa-solid fa-arrow-left text-[9px]" />
Return
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col">
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
<Link
href="/"
className="text-[10px] smallcaps text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2"
>
<i className="fa-solid fa-arrow-left text-[9px]" />
Dada
</Link>
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
<span>Frame · {String(turnNum).padStart(3, "0")}</span>
<span className="text-clay-300">·</span>
<span className="hidden sm:inline truncate max-w-[180px]">
{session?.id.slice(2, 14) ?? "—"}
</span>
</div>
</header>
<main className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-6 md:py-10">
<PlayCanvas
imageBase64={imageBase64}
phase={phase}
pendingClick={pendingClick}
onClick={handleClick}
/>
<div className="mt-7 md:mt-9 max-w-md w-full text-center min-h-[64px] flex items-center justify-center">
{phase === "loading-first" && (
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
Summoning · the · first · frame
</p>
)}
{phase === "interacting" && (
<div className="flex flex-col items-center gap-2 animate-fade-in">
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
AI · is · painting · the · next · moment
</p>
<p className="font-serif italic text-clay-400 text-xs">
this usually takes 1220 seconds
</p>
</div>
)}
{phase === "ready" && intent?.targetLabel && (
<p className="font-serif italic text-clay-500 text-base leading-relaxed animate-fade-in max-w-[320px]">
<span className="text-[9px] smallcaps not-italic text-clay-400 mr-2 align-middle">
Last · move ·
</span>
<span className="align-middle">{intent.targetLabel}</span>
</p>
)}
{phase === "ready" && !intent && turnNum > 0 && (
<p className="text-[10px] smallcaps text-clay-400 animate-fade-in">
Click · anywhere · to · respond
</p>
)}
</div>
</main>
<footer className="px-5 md:px-12 pb-6">
<div className="text-[9px] smallcaps text-clay-400 text-center num">
·
</div>
</footer>
</div>
);
}
export default function PlayPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<span className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
Loading
</span>
</div>
}
>
<PlayInner />
</Suspense>
);
}
+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>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { EngineConfig } from "@dada/types";
function readVar(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required environment variable: ${name}`);
return v;
}
export function loadEngineConfig(): EngineConfig {
return {
text: {
baseUrl: readVar("TEXT_BASE_URL"),
apiKey: readVar("TEXT_API_KEY"),
model: readVar("TEXT_MODEL"),
},
image: {
baseUrl: readVar("IMAGE_BASE_URL"),
apiKey: readVar("IMAGE_API_KEY"),
model: readVar("IMAGE_MODEL"),
},
vision: {
baseUrl: readVar("VISION_BASE_URL"),
apiKey: readVar("VISION_API_KEY"),
model: readVar("VISION_MODEL"),
},
};
}
+37
View File
@@ -0,0 +1,37 @@
export type Preset = {
id: string;
title: string;
blurb: string;
worldSetting: string;
styleGuide: string;
};
export const PRESETS: Preset[] = [
{
id: "highschool",
title: "六月雨季",
blurb: "县城高中,转学生,未送出的伞。",
worldSetting:
"故事发生在 1990 年代末的中国南方县城高中。主角是高三转学生,在多雨的六月遇到一个总在天台读诗的同学。剧情慢热、含蓄、带点伤感。",
styleGuide:
"Anime visual novel style, soft watercolor lighting, warm afternoon palette, classic Japanese galgame dialogue panel.",
},
{
id: "cyberpunk",
title: "雨夜霓虹",
blurb: "失忆的私家侦探,一通陌生来电。",
worldSetting:
"2087 年的雨夜东亚特区。主角是一个刚从昏迷中醒来、丢失了三天记忆的私家侦探。他的电话响了,对面是一个声称认识他的女人。",
styleGuide:
"Cinematic cyberpunk realism, neon reflections on wet streets, blade-runner palette, transparent neon HUD interface elements.",
},
{
id: "stickfigure",
title: "火柴人冒险",
blurb: "一支铅笔,一个世界,全靠涂改。",
worldSetting:
"你是一个用铅笔画在格子本上的火柴人,刚意识到自己活在一个学生的草稿纸里。本子的边缘正在被橡皮擦逐渐抹去,你必须想办法逃出去。",
styleGuide:
"Hand-drawn pencil sketch on grid paper, stick figures, rough doodle UI elements, eraser smudges, notebook aesthetic.",
},
];
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
+14
View File
@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const config: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@dada/engine", "@dada/ai-client", "@dada/types"],
serverExternalPackages: ["sharp"],
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};
export default config;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@dada/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dada/ai-client": "workspace:*",
"@dada/engine": "workspace:*",
"@dada/types": "workspace:*",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+57
View File
@@ -0,0 +1,57 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
cream: {
50: "#FBF7F0",
100: "#F5EFE3",
200: "#EBE0CB",
300: "#DCC9A8",
},
clay: {
400: "#C68B5C",
500: "#A8693B",
600: "#854F25",
700: "#5E371A",
900: "#2D1810",
},
ember: {
400: "#E89B5C",
500: "#D97A2E",
},
},
fontFamily: {
serif: ['"Cormorant Garamond"', '"Source Han Serif SC"', "ui-serif", "Georgia", "serif"],
sans: ['"Inter"', '"PingFang SC"', "ui-sans-serif", "system-ui", "sans-serif"],
},
letterSpacing: {
widest: "0.32em",
},
animation: {
"fade-in": "fadeIn 0.6s ease-out",
"slow-pulse": "slowPulse 2.6s ease-in-out infinite",
"drift": "drift 12s ease-in-out infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0", transform: "translateY(8px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slowPulse: {
"0%, 100%": { opacity: "0.55" },
"50%": { opacity: "1" },
},
drift: {
"0%, 100%": { transform: "translate(0, 0)" },
"50%": { transform: "translate(0, -10px)" },
},
},
},
},
plugins: [],
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}