Build MVP: launcher, constructor, and AI game generation

A self-hosted, Roblox-flavored game platform: browse/play published
browser games, build them by hand or by describing them to an LLM.
Fully independent from top-tickets — separate repo, DB, port (8082).

- Auth: email+password accounts (argon2, httpOnly session cookies),
  no admin/agent split — every account can create and play.
- Three game templates (quiz, clicker, maze), each a zod schema paired
  with a Phaser 4 Scene that renders purely from a validated instance
  of that schema — manual and AI-generated games hit the same
  renderer, so a saved game is always guaranteed to load and run. The
  maze schema BFS-validates that the exit is reachable from the start.
- Manual constructor: per-template builder forms, draft/publish/edit/
  delete, a launcher grid of published games, a full-bleed play page.
- AI generation: Anthropic API key configured under Settings → AI
  (encrypted at rest, validated with a live call before saving, same
  shape as top-tickets' mailbox/LDAP settings). Generation uses the
  SDK's zodOutputFormat/messages.parse to force schema-shaped JSON
  output, with one retry feeding back the validation error. Output
  always prefills the manual builder for review — never auto-published
  blind. Clean, distinct error message when no key is configured yet
  (no silent fallback exists here, so it doesn't pretend one does).
- Docker Compose on port 8082 (bakes in the busy_timeout=30000 SQLite
  pragma from day one — top-tickets hit this build-time race twice).

Deferred: more templates/genres, drag-and-drop level editor, likes/
leaderboards, AI-generated sprite assets, multiplayer, moderation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-31 17:40:30 +00:00
1 parent df16c90832
commit c9a6c27a7a
60 files changed
+5601 -93

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Generate with: npm run generate-key
CREDENTIALS_ENCRYPTION_KEY=
# Where the SQLite DB lives — set by docker-compose in production, defaults
# to ./data for local dev.
DATA_DIR=./data
+4
View File
@@ -32,6 +32,10 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# app data (SQLite db, WAL files)
/data/
# vercel # vercel
.vercel .vercel
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-bookworm-slim
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
# source if no prebuilt binary matches this platform.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV NODE_ENV=production
EXPOSE 8082
# Migrate the (volume-mounted) SQLite DB on every start — a no-op once
# already applied.
CMD ["sh", "-c", "npm run db:migrate && npm start"]
+16
View File
@@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "8082:8082"
env_file:
- .env
environment:
- PORT=8082
- DATA_DIR=/data
volumes:
- data:/data
restart: unless-stopped
volumes:
data:
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import path from "node:path";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
export default defineConfig({
out: "./src/lib/db/migrations",
schema: "./src/lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: path.join(dataDir, "db.sqlite"),
},
});
+4 -1
View File
@@ -1,7 +1,10 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ // Not using output: "standalone" — file-tracing only copies argon2's
// native .node binaries, not its JS loader, causing a segfault in
// production. Deploying with the full node_modules via `next start` is
// simple enough for a single-VM deployment.
}; };
export default nextConfig; export default nextConfig;
+2095 -13
View File
File diff suppressed because it is too large. Load diff
+16 -3
View File
@@ -5,22 +5,35 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p ${PORT:-8082}",
"lint": "eslint" "lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"generate-key": "tsx scripts/generate-key.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.28.0",
"next": "16.2.12", "next": "16.2.12",
"phaser": "^4.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.12", "eslint-config-next": "16.2.12",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.1",
"typescript": "^5" "typescript": "^5"
} }
} }
+7
View File
@@ -0,0 +1,7 @@
/**
* Generates a fresh AES-256-GCM key for CREDENTIALS_ENCRYPTION_KEY.
* Usage: npm run generate-key
*/
import crypto from "node:crypto";
console.log(crypto.randomBytes(32).toString("base64"));
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LogIn } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
return (
<div className="mx-auto flex w-full max-w-sm flex-1 items-center justify-center px-4 py-12">
<form onSubmit={handleSubmit} className="card w-full p-8">
<h1 className="mb-1 font-display text-xl font-semibold">Вход</h1>
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы создавать и публиковать игры.</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<LogIn size={16} />
{loading ? "Входим…" : "Войти"}
</button>
<p className="mt-4 text-center text-sm text-text-muted">
Нет аккаунта?{" "}
<Link href="/register" className="font-medium text-accent hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось зарегистрироваться");
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
return (
<div className="mx-auto flex w-full max-w-sm flex-1 items-center justify-center px-4 py-12">
<form onSubmit={handleSubmit} className="card w-full p-8">
<h1 className="mb-1 font-display text-xl font-semibold">Регистрация</h1>
<p className="mb-6 text-sm text-text-muted">Создайте аккаунт, чтобы делать свои игры.</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Имя</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<UserPlus size={16} />
{loading ? "Создаём…" : "Зарегистрироваться"}
</button>
<p className="mt-4 text-center text-sm text-text-muted">
Уже есть аккаунт?{" "}
<Link href="/login" className="font-medium text-accent hover:underline">
Войти
</Link>
</p>
</form>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getAiStatus, saveAiSettings, disableAi } from "@/lib/ai/config";
import { testAnthropicKey } from "@/lib/ai/client";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getAiStatus());
}
const configureSchema = z.object({
apiKey: z.string().min(1),
model: z.string().min(1),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
await testAnthropicKey(parsed.data.apiKey, parsed.data.model);
await saveAiSettings(parsed.data);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
}
}
export async function DELETE() {
const { session, response } = await requireSession();
if (!session) return response;
await disableAi();
return NextResponse.json({ ok: true });
}
+55
View File
@@ -0,0 +1,55 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { generateGameDefinition, AiNotConfiguredError } from "@/lib/ai/generate";
import { TEMPLATE_TYPES } from "@/lib/games/types";
// This costs real API spend once a key is configured, so the window/limit
// is tighter than the plain login rate limiter.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 15;
const WINDOW_MS = 60 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
const generateSchema = z.object({
templateType: z.enum(TEMPLATE_TYPES),
prompt: z.string().min(3).max(2000),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
if (isRateLimited(session.user.id)) {
return NextResponse.json({ error: "Слишком много запросов к ИИ — попробуйте позже" }, { status: 429 });
}
const body = await request.json().catch(() => null);
const parsed = generateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
try {
const definition = await generateGameDefinition(parsed.data.templateType, parsed.data.prompt);
return NextResponse.json({ definition });
} catch (err) {
if (err instanceof AiNotConfiguredError) {
return NextResponse.json({ error: "ИИ не настроен — добавьте ключ в Настройки → ИИ" }, { status: 400 });
}
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось сгенерировать игру: ${message}` }, { status: 502 });
}
}
+61
View File
@@ -0,0 +1,61 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
}
const user = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.email, email) });
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ??
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !ok) {
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name } });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const registerSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8, "Пароль должен быть не короче 8 символов"),
});
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const passwordHash = await hashPassword(parsed.data.password);
let user;
try {
[user] = await db
.insert(users)
.values({ name: parsed.data.name, email, passwordHash })
.returning();
} catch {
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name } });
}
+61
View File
@@ -0,0 +1,61 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getGameById, updateGame, deleteGame } from "@/lib/games/service";
import { safeParseDefinition } from "@/lib/games/types";
const patchSchema = z.object({
title: z.string().min(1).max(120).optional(),
description: z.string().max(500).nullable().optional(),
definition: z.unknown().optional(),
status: z.enum(["draft", "published"]).optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const existing = await getGameById(id);
if (!existing || existing.ownerId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
let definition = undefined;
if (parsed.data.definition !== undefined) {
const defResult = safeParseDefinition(existing.templateType, parsed.data.definition);
if (!defResult.success) {
return NextResponse.json({ error: defResult.error.issues[0]?.message ?? "Invalid definition" }, { status: 400 });
}
definition = defResult.data;
}
const game = await updateGame(id, session.user.id, {
title: parsed.data.title,
description: parsed.data.description,
definition,
status: parsed.data.status,
});
return NextResponse.json({ game });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const ok = await deleteGame(id, session.user.id);
if (!ok) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { createGame } from "@/lib/games/service";
import { TEMPLATE_TYPES, safeParseDefinition } from "@/lib/games/types";
const createSchema = z.object({
title: z.string().min(1).max(120),
description: z.string().max(500).optional(),
templateType: z.enum(TEMPLATE_TYPES),
definition: z.unknown(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
const defResult = safeParseDefinition(parsed.data.templateType, parsed.data.definition);
if (!defResult.success) {
return NextResponse.json({ error: defResult.error.issues[0]?.message ?? "Invalid definition" }, { status: 400 });
}
const game = await createGame({
ownerId: session.user.id,
title: parsed.data.title,
description: parsed.data.description,
templateType: parsed.data.templateType,
definition: defResult.data,
});
return NextResponse.json({ game }, { status: 201 });
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect, notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType } from "@/lib/games/types";
import { QuizBuilder } from "@/components/builders/quiz-builder";
import { ClickerBuilder } from "@/components/builders/clicker-builder";
import { MazeBuilder } from "@/components/builders/maze-builder";
export default async function ManualCreatePage({ params }: { params: Promise<{ template: string }> }) {
const { template } = await params;
if (!TEMPLATE_TYPES.includes(template as TemplateType)) notFound();
const templateType = template as TemplateType;
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">
Новая игра — {TEMPLATE_LABELS[templateType]}
</h1>
<p className="mb-6 text-sm text-text-muted">Сохранится как черновик — опубликуете, когда будете готовы.</p>
{templateType === "quiz" && <QuizBuilder mode="create" />}
{templateType === "clicker" && <ClickerBuilder mode="create" />}
{templateType === "maze" && <MazeBuilder mode="create" />}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { AiGenerateForm } from "@/components/ai-generate-form";
export default async function AiCreatePage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Создать с помощью ИИ</h1>
<p className="mb-6 text-sm text-text-muted">
Опишите игру словами — ИИ заполнит шаблон, вы проверите и подправите перед сохранением.
</p>
<AiGenerateForm />
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { HelpCircle, MousePointerClick, Map as MapIcon, Sparkles } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_TYPES, TEMPLATE_LABELS, TEMPLATE_DESCRIPTIONS, type TemplateType } from "@/lib/games/types";
const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
quiz: HelpCircle,
clicker: MousePointerClick,
maze: MapIcon,
};
export default async function CreatePage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Создать игру</h1>
<p className="mb-8 text-sm text-text-muted">Выберите шаблон — вручную или с помощью ИИ по описанию.</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{TEMPLATE_TYPES.map((template) => {
const Icon = TEMPLATE_ICONS[template];
return (
<Link
key={template}
href={`/create/${template}/manual`}
className="card flex flex-col gap-3 p-5 transition-transform hover:-translate-y-0.5"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
<Icon size={20} />
</div>
<h2 className="font-display text-lg font-semibold">{TEMPLATE_LABELS[template]}</h2>
<p className="text-sm text-text-muted">{TEMPLATE_DESCRIPTIONS[template]}</p>
</Link>
);
})}
</div>
<Link
href="/create/ai"
className="card mt-4 flex items-center gap-3 p-5 transition-transform hover:-translate-y-0.5"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
<Sparkles size={20} />
</div>
<div>
<h2 className="font-display text-lg font-semibold">Создать с помощью ИИ</h2>
<p className="text-sm text-text-muted">Опишите игру словами — ИИ заполнит шаблон, вы проверите и сохраните.</p>
</div>
</Link>
</div>
);
}
+158 -13
View File
@@ -1,26 +1,171 @@
@import "tailwindcss"; @import "tailwindcss";
:root { @theme {
--background: #ffffff; --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--foreground: #171717; --font-display: var(--font-display), var(--font-sans), ui-sans-serif, sans-serif;
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-text: var(--text);
--color-text-muted: var(--text-muted);
--color-text-faint: var(--text-faint);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-text: var(--accent-soft-text);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-danger-soft-text: var(--danger-soft-text);
--color-success: var(--success);
--color-success-soft: var(--success-soft);
--color-success-soft-text: var(--success-soft-text);
--radius-sm: var(--radius-sm-val);
--radius-md: var(--radius-md-val);
--radius-lg: var(--radius-lg-val);
} }
@theme inline { :root {
--color-background: var(--background); --bg: #f7f8fb;
--color-foreground: var(--foreground); --surface: #ffffff;
--font-sans: var(--font-geist-sans); --surface-hover: #eef1fa;
--font-mono: var(--font-geist-mono); --border: #e1e5f0;
--text: #14162b;
--text-muted: #5c6178;
--text-faint: #949ab3;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-soft: #ebeafe;
--accent-soft-text: #4338ca;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-soft-text: #b91c1c;
--success: #0d9488;
--success-soft: #ecfdf9;
--success-soft-text: #0f766e;
--radius-sm-val: 8px;
--radius-md-val: 12px;
--radius-lg-val: 18px;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
color-scheme: light;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --bg: #0a0b14;
--foreground: #ededed; --surface: #14162a;
--surface-hover: #1b1e36;
--border: #292d4a;
--text: #eef0fb;
--text-muted: #a3a8c6;
--text-faint: #666b8c;
--accent: #818cf8;
--accent-hover: #6366f1;
--accent-soft: #23264a;
--accent-soft-text: #c7d2fe;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
--card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
} }
} }
* {
border-color: var(--border);
}
html,
body { body {
background: var(--background); height: 100%;
color: var(--foreground); }
font-family: Arial, Helvetica, sans-serif;
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--card-shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 0.9rem;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.5;
pointer-events: none;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--surface-hover);
color: var(--text);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-soft-text);
} }
+10 -6
View File
@@ -1,20 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Header } from "@/components/header";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin", "cyrillic"],
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin", "cyrillic"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "tis — топ-игроструктор",
description: "Generated by create next app", description: "Лаунчер и конструктор браузерных игр",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,10 +25,13 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="ru"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<Header />
<main className="flex flex-1 flex-col">{children}</main>
</body>
</html> </html>
); );
} }
+64
View File
@@ -0,0 +1,64 @@
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { Play } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getGameById } from "@/lib/games/service";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import { QuizBuilder } from "@/components/builders/quiz-builder";
import { ClickerBuilder } from "@/components/builders/clicker-builder";
import { MazeBuilder } from "@/components/builders/maze-builder";
import type { QuizDefinition } from "@/lib/games/quiz";
import type { ClickerDefinition } from "@/lib/games/clicker";
import type { MazeDefinition } from "@/lib/games/maze";
export default async function EditGamePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getCurrentSession();
if (!session) redirect("/login");
const game = await getGameById(id);
if (!game || game.ownerId !== session.user.id) notFound();
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<span className="badge mb-1">{TEMPLATE_LABELS[game.templateType]}</span>
<h1 className="font-display text-2xl font-bold tracking-tight">{game.title}</h1>
</div>
<Link href={`/play/${game.id}`} className="btn btn-ghost">
<Play size={14} />
Играть
</Link>
</div>
{game.templateType === "quiz" && (
<QuizBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as QuizDefinition}
/>
)}
{game.templateType === "clicker" && (
<ClickerBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as ClickerDefinition}
/>
)}
{game.templateType === "maze" && (
<MazeBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as MazeDefinition}
/>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listGamesByOwner } from "@/lib/games/service";
import { MyGamesList } from "@/components/my-games-list";
export default async function MyGamesPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const games = await listGamesByOwner(session.user.id);
return (
<div className="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Мои игры</h1>
<p className="mb-6 text-sm text-text-muted">Управляйте своими играми — редактируйте, публикуйте, удаляйте.</p>
<MyGamesList initialGames={games} />
</div>
);
}
+36 -57
View File
@@ -1,65 +1,44 @@
import Image from "next/image"; import Link from "next/link";
import { Gamepad2, Plus } from "lucide-react";
import { listPublishedGames } from "@/lib/games/service";
import { getCurrentSession } from "@/lib/auth/session";
import { GameCard } from "@/components/game-card";
export default async function Home() {
const [games, session] = await Promise.all([listPublishedGames(), getCurrentSession()]);
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<Image <div>
className="dark:invert" <h1 className="font-display text-2xl font-bold tracking-tight">Лаунчер</h1>
src="/next.svg" <p className="mt-1 text-sm text-text-muted">Играйте в игры, которые сделали другие пользователи.</p>
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> <Link href={session ? "/create" : "/register"} className="btn btn-primary">
<a <Plus size={16} />
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" Создать игру
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" </Link>
target="_blank" </div>
rel="noopener noreferrer"
> {games.length === 0 ? (
<Image <div className="card flex flex-col items-center gap-3 p-12 text-center">
className="dark:invert" <Gamepad2 size={32} className="text-text-faint" />
src="/vercel.svg" <p className="text-text-muted">Пока нет опубликованных игр — будьте первым, кто её создаст.</p>
alt="Vercel logomark" </div>
width={16} ) : (
height={16} <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{games.map((game) => (
<GameCard
key={game.id}
id={game.id}
title={game.title}
description={game.description}
templateType={game.templateType}
playCount={game.playCount}
/> />
Deploy Now ))}
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main> )}
</div> </div>
); );
} }
+39
View File
@@ -0,0 +1,39 @@
import { notFound } from "next/navigation";
import { getGameById, incrementPlayCount } from "@/lib/games/service";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import { PlaySession } from "@/components/play-session";
const TEMPLATE_HINTS: Record<string, string> = {
quiz: "Выбирайте вариант ответа мышкой.",
clicker: "Кликайте по кругу и покупайте улучшения.",
maze: "Управление — стрелки на клавиатуре.",
};
export default async function PlayPage({ params }: { params: Promise<{ gameId: string }> }) {
const { gameId } = await params;
const game = await getGameById(gameId);
if (!game) notFound();
if (game.status === "draft") {
const session = await getCurrentSession();
if (!session || session.user.id !== game.ownerId) notFound();
} else {
await incrementPlayCount(gameId);
}
return (
<div className="flex flex-1 flex-col">
<div className="mx-auto w-full max-w-4xl px-4 pt-6 sm:px-6">
<div className="mb-1 flex items-center gap-2">
<span className="badge">{TEMPLATE_LABELS[game.templateType]}</span>
{game.status === "draft" && <span className="badge bg-danger-soft text-danger-soft-text">Черновик</span>}
</div>
<h1 className="font-display text-2xl font-bold tracking-tight">{game.title}</h1>
{game.description && <p className="mt-1 text-sm text-text-muted">{game.description}</p>}
<p className="mt-1 text-xs text-text-faint">{TEMPLATE_HINTS[game.templateType]}</p>
</div>
<PlaySession templateType={game.templateType} definition={game.definition} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { getAiStatus } from "@/lib/ai/config";
import { AiSettingsForm } from "@/components/ai-settings-form";
export default async function AiSettingsPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const status = await getAiStatus();
return (
<div className="mx-auto w-full max-w-lg flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Настройки ИИ</h1>
<p className="mb-6 text-sm text-text-muted">
Ключ Anthropic API — нужен, чтобы создавать игры по текстовому описанию. Отдельный от того, чем управляется
Claude Code — со своей оплатой.
</p>
<AiSettingsForm initialStatus={status} />
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Sparkles } from "lucide-react";
import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType, type GameDefinition } from "@/lib/games/types";
import type { QuizDefinition } from "@/lib/games/quiz";
import type { ClickerDefinition } from "@/lib/games/clicker";
import type { MazeDefinition } from "@/lib/games/maze";
import { QuizBuilder } from "@/components/builders/quiz-builder";
import { ClickerBuilder } from "@/components/builders/clicker-builder";
import { MazeBuilder } from "@/components/builders/maze-builder";
export function AiGenerateForm() {
const [templateType, setTemplateType] = useState<TemplateType>("quiz");
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [definition, setDefinition] = useState<GameDefinition | null>(null);
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setDefinition(null);
const res = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ templateType, prompt }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сгенерировать игру");
return;
}
const data = await res.json();
setDefinition(data.definition);
}
if (definition) {
return (
<div className="flex flex-col gap-4">
<p className="rounded-md bg-accent-soft px-3 py-2 text-sm text-accent-soft-text">
Игра сгенерирована — проверьте и подправьте перед сохранением.
</p>
{templateType === "quiz" && (
<QuizBuilder mode="create" initialTitle={title} initialDefinition={definition as QuizDefinition} />
)}
{templateType === "clicker" && (
<ClickerBuilder mode="create" initialTitle={title} initialDefinition={definition as ClickerDefinition} />
)}
{templateType === "maze" && (
<MazeBuilder mode="create" initialTitle={title} initialDefinition={definition as MazeDefinition} />
)}
</div>
);
}
return (
<form onSubmit={handleGenerate} className="card flex flex-col gap-4 p-5">
<div className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Тип игры</span>
<div className="flex gap-2">
{TEMPLATE_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => setTemplateType(t)}
className={`btn ${templateType === t ? "btn-primary" : "btn-ghost"}`}
>
{TEMPLATE_LABELS[t]}
</button>
))}
</div>
</div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название игры</span>
<input
required
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Опишите игру</span>
<textarea
required
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Например: викторина на 5 вопросов про космос для детей"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading || !title || !prompt} className="btn btn-primary self-start">
<Sparkles size={15} />
{loading ? "Генерируем…" : "Сгенерировать"}
</button>
</form>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
interface Status {
configured: boolean;
enabled: boolean;
model: string;
verifiedAt: number | null;
}
export function AiSettingsForm({ initialStatus }: { initialStatus: Status }) {
const [status, setStatus] = useState(initialStatus);
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState(initialStatus.model);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/ai/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey, model }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подключить ИИ");
return;
}
setStatus({ configured: true, enabled: true, model, verifiedAt: Date.now() });
setApiKey("");
}
async function handleDisable() {
setLoading(true);
await fetch("/api/ai/config", { method: "DELETE" });
setLoading(false);
setStatus((s) => ({ ...s, enabled: false }));
}
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2 text-sm">
{status.enabled ? (
<>
<CheckCircle2 size={16} className="text-success" />
<span>Подключено: {status.model}</span>
</>
) : (
<>
<XCircle size={16} className="text-text-faint" />
<span className="text-text-muted">ИИ не подключён</span>
</>
)}
</div>
<form onSubmit={handleConnect}>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Anthropic API key</span>
<input
required
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Модель</span>
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading || !apiKey} className="btn btn-primary">
{status.configured ? "Обновить ключ" : "Подключить"}
</button>
{status.enabled && (
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
Отключить
</button>
)}
</div>
</form>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react";
import {
clickerDefinitionSchema,
DEFAULT_CLICKER_DEFINITION,
type ClickerDefinition,
type ClickerUpgrade,
} from "@/lib/games/clicker";
export function ClickerBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: ClickerDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [targetLabel, setTargetLabel] = useState(initialDefinition?.targetLabel ?? DEFAULT_CLICKER_DEFINITION.targetLabel);
const [targetScore, setTargetScore] = useState(initialDefinition?.targetScore ?? DEFAULT_CLICKER_DEFINITION.targetScore);
const [startingPointsPerClick, setStartingPointsPerClick] = useState(
initialDefinition?.startingPointsPerClick ?? DEFAULT_CLICKER_DEFINITION.startingPointsPerClick,
);
const [upgrades, setUpgrades] = useState<ClickerUpgrade[]>(
initialDefinition?.upgrades ?? DEFAULT_CLICKER_DEFINITION.upgrades,
);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function updateUpgrade(index: number, patch: Partial<ClickerUpgrade>) {
setUpgrades((ups) => ups.map((u, i) => (i === index ? { ...u, ...patch } : u)));
}
function addUpgrade() {
setUpgrades((ups) => [...ups, { name: "", cost: 10, pointsPerClickBonus: 0, pointsPerSecondBonus: 0 }]);
}
function removeUpgrade(index: number) {
setUpgrades((ups) => ups.filter((_, i) => i !== index));
}
async function handleSave() {
setError(null);
const definition: ClickerDefinition = { targetLabel, targetScore, startingPointsPerClick, upgrades };
const parsed = clickerDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте настройки — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "clicker", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название цели</span>
<input
value={targetLabel}
onChange={(e) => setTargetLabel(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Очков для победы</span>
<input
type="number"
min={10}
value={targetScore}
onChange={(e) => setTargetScore(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Очков за клик (старт)</span>
<input
type="number"
min={1}
value={startingPointsPerClick}
onChange={(e) => setStartingPointsPerClick(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
</div>
<div className="card flex flex-col gap-3 p-4">
<span className="text-sm font-semibold text-text-muted">Улучшения</span>
{upgrades.map((upgrade, index) => (
<div key={index} className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2">
<input
value={upgrade.name}
onChange={(e) => updateUpgrade(index, { name: e.target.value })}
placeholder="Название"
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
/>
<input
type="number"
min={1}
value={upgrade.cost}
onChange={(e) => updateUpgrade(index, { cost: Number(e.target.value) })}
title="Цена"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<input
type="number"
min={0}
value={upgrade.pointsPerClickBonus}
onChange={(e) => updateUpgrade(index, { pointsPerClickBonus: Number(e.target.value) })}
title="Бонус за клик"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<input
type="number"
min={0}
value={upgrade.pointsPerSecondBonus}
onChange={(e) => updateUpgrade(index, { pointsPerSecondBonus: Number(e.target.value) })}
title="Бонус в секунду"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<button onClick={() => removeUpgrade(index)} className="text-text-faint hover:text-danger">
<Trash2 size={14} />
</button>
</div>
))}
<div className="flex gap-4 text-xs text-text-faint">
<span>Цена</span>
<span className="ml-auto">За клик</span>
<span>За сек</span>
</div>
<button onClick={addUpgrade} className="btn btn-ghost self-start">
<Plus size={15} />
Добавить улучшение
</button>
</div>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Save } from "lucide-react";
import { mazeDefinitionSchema, DEFAULT_MAZE_DEFINITION, type MazeDefinition } from "@/lib/games/maze";
export function MazeBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: MazeDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [gridText, setGridText] = useState((initialDefinition?.grid ?? DEFAULT_MAZE_DEFINITION.grid).join("\n"));
const [timeLimitSec, setTimeLimitSec] = useState(initialDefinition?.timeLimitSec ?? 0);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSave() {
setError(null);
const grid = gridText.split("\n").map((row) => row.trimEnd()).filter((row) => row.length > 0);
const definition: MazeDefinition = { grid, timeLimitSec };
const parsed = mazeDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте лабиринт — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "maze", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="w-48 text-sm">
<span className="mb-1 block font-medium text-text-muted">Лимит времени, сек (0 — без лимита)</span>
<input
type="number"
min={0}
max={600}
value={timeLimitSec}
onChange={(e) => setTimeLimitSec(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
<div className="card flex flex-col gap-2 p-4">
<span className="text-sm font-medium text-text-muted">
Лабиринт — каждая строка ряд. <code className="text-text-faint">#</code> стена,{" "}
<code className="text-text-faint">.</code> пол, <code className="text-text-faint">S</code> старт (один),{" "}
<code className="text-text-faint">E</code> выход, <code className="text-text-faint">*</code> предмет.
</span>
<textarea
value={gridText}
onChange={(e) => setGridText(e.target.value)}
rows={12}
spellCheck={false}
className="w-full resize-y rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</div>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react";
import { quizDefinitionSchema, DEFAULT_QUIZ_DEFINITION, type QuizDefinition, type QuizQuestion } from "@/lib/games/quiz";
export function QuizBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: QuizDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [questions, setQuestions] = useState<QuizQuestion[]>(
initialDefinition?.questions ?? DEFAULT_QUIZ_DEFINITION.questions,
);
const [timePerQuestionSec, setTimePerQuestionSec] = useState(initialDefinition?.timePerQuestionSec ?? 0);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function updateQuestion(index: number, patch: Partial<QuizQuestion>) {
setQuestions((qs) => qs.map((q, i) => (i === index ? { ...q, ...patch } : q)));
}
function updateOption(qIndex: number, optIndex: number, value: string) {
setQuestions((qs) =>
qs.map((q, i) => (i === qIndex ? { ...q, options: q.options.map((o, j) => (j === optIndex ? value : o)) } : q)),
);
}
function addQuestion() {
setQuestions((qs) => [...qs, { question: "", options: ["", ""], correctIndex: 0 }]);
}
function removeQuestion(index: number) {
setQuestions((qs) => qs.filter((_, i) => i !== index));
}
function addOption(qIndex: number) {
setQuestions((qs) => qs.map((q, i) => (i === qIndex && q.options.length < 6 ? { ...q, options: [...q.options, ""] } : q)));
}
function removeOption(qIndex: number, optIndex: number) {
setQuestions((qs) =>
qs.map((q, i) => {
if (i !== qIndex || q.options.length <= 2) return q;
const options = q.options.filter((_, j) => j !== optIndex);
const correctIndex = q.correctIndex >= options.length ? 0 : q.correctIndex;
return { ...q, options, correctIndex };
}),
);
}
async function handleSave() {
setError(null);
const definition: QuizDefinition = { questions, timePerQuestionSec };
const parsed = quizDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте вопросы — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "quiz", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="w-48 text-sm">
<span className="mb-1 block font-medium text-text-muted">Время на вопрос, сек (0 — без таймера)</span>
<input
type="number"
min={0}
max={120}
value={timePerQuestionSec}
onChange={(e) => setTimePerQuestionSec(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
{questions.map((q, qIndex) => (
<div key={qIndex} className="card flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text-muted">Вопрос {qIndex + 1}</span>
{questions.length > 1 && (
<button onClick={() => removeQuestion(qIndex)} className="btn btn-ghost text-danger">
<Trash2 size={14} />
</button>
)}
</div>
<input
value={q.question}
onChange={(e) => updateQuestion(qIndex, { question: e.target.value })}
placeholder="Текст вопроса"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
{q.options.map((option, optIndex) => (
<div key={optIndex} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${qIndex}`}
checked={q.correctIndex === optIndex}
onChange={() => updateQuestion(qIndex, { correctIndex: optIndex })}
title="Правильный ответ"
/>
<input
value={option}
onChange={(e) => updateOption(qIndex, optIndex, e.target.value)}
placeholder={`Вариант ${optIndex + 1}`}
className="flex-1 rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
/>
{q.options.length > 2 && (
<button onClick={() => removeOption(qIndex, optIndex)} className="text-text-faint hover:text-danger">
<Trash2 size={13} />
</button>
)}
</div>
))}
{q.options.length < 6 && (
<button onClick={() => addOption(qIndex)} className="btn btn-ghost self-start text-xs">
<Plus size={12} />
Вариант
</button>
)}
</div>
))}
<button onClick={addQuestion} className="btn btn-ghost self-start">
<Plus size={15} />
Добавить вопрос
</button>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef } from "react";
import type Phaser from "phaser";
import type { TemplateType, GameDefinition, GameResult } from "@/lib/games/types";
export function GameCanvas({
templateType,
definition,
onFinish,
}: {
templateType: TemplateType;
definition: GameDefinition;
onFinish?: (result: GameResult) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let game: Phaser.Game | null = null;
let cancelled = false;
(async () => {
const [{ default: PhaserLib }, { sceneClassFor }] = await Promise.all([
import("phaser"),
import("@/lib/games/scenes"),
]);
if (cancelled || !containerRef.current) return;
const SceneClass = sceneClassFor(templateType);
game = new PhaserLib.Game({
type: PhaserLib.AUTO,
parent: containerRef.current,
backgroundColor: "#0a0b14",
scale: {
mode: PhaserLib.Scale.FIT,
autoCenter: PhaserLib.Scale.CENTER_BOTH,
width: 960,
height: 600,
},
scene: [SceneClass],
});
game.registry.set("definition", definition);
game.registry.set("onFinish", onFinish);
})();
return () => {
cancelled = true;
game?.destroy(true);
};
// Intentionally only re-creating the game when the template changes —
// callers that need a fresh definition/result loaded should force a
// remount via a `key` prop rather than mutating props in place.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateType]);
return <div ref={containerRef} className="aspect-[8/5] w-full overflow-hidden rounded-lg bg-black" />;
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import { HelpCircle, MousePointerClick, Map as MapIcon, Play } from "lucide-react";
import { TEMPLATE_LABELS, type TemplateType } from "@/lib/games/types";
const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
quiz: HelpCircle,
clicker: MousePointerClick,
maze: MapIcon,
};
export function GameCard({
id,
title,
description,
templateType,
playCount,
}: {
id: string;
title: string;
description: string | null;
templateType: TemplateType;
playCount: number;
}) {
const Icon = TEMPLATE_ICONS[templateType];
return (
<Link href={`/play/${id}`} className="card group flex flex-col gap-3 p-4 transition-transform hover:-translate-y-0.5">
<div className="flex items-center gap-2">
<span className="badge">
<Icon size={13} />
{TEMPLATE_LABELS[templateType]}
</span>
</div>
<h3 className="font-display text-lg font-semibold leading-snug">{title}</h3>
{description && <p className="line-clamp-2 text-sm text-text-muted">{description}</p>}
<div className="mt-auto flex items-center justify-between pt-2 text-xs text-text-faint">
<span>{playCount} игр сыграно</span>
<span className="flex items-center gap-1 font-medium text-accent group-hover:underline">
<Play size={12} />
Играть
</span>
</div>
</Link>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { Gamepad2 } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { LogoutButton } from "./logout-button";
export async function Header() {
const session = await getCurrentSession();
return (
<header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
<Gamepad2 size={15} strokeWidth={2.5} />
</div>
tis
</Link>
<nav className="flex items-center gap-1 text-sm font-medium text-text-muted">
<Link href="/" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Лаунчер
</Link>
{session && (
<>
<Link href="/create" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Создать
</Link>
<Link href="/my-games" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Мои игры
</Link>
<Link href="/settings/ai" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Настройки ИИ
</Link>
</>
)}
</nav>
<div className="ml-auto flex items-center gap-2">
{session ? (
<>
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
<LogoutButton />
</>
) : (
<>
<Link href="/login" className="btn btn-ghost">
Войти
</Link>
<Link href="/register" className="btn btn-primary">
Регистрация
</Link>
</>
)}
</div>
</div>
</header>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/");
router.refresh();
}
return (
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
<LogOut size={15} />
</button>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Play, Pencil, Trash2, Eye, EyeOff } from "lucide-react";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import type { GameSummaryDTO } from "@/lib/games/service";
export function MyGamesList({ initialGames }: { initialGames: GameSummaryDTO[] }) {
const [games, setGames] = useState(initialGames);
const [error, setError] = useState<string | null>(null);
async function togglePublish(game: GameSummaryDTO) {
setError(null);
const nextStatus = game.status === "published" ? "draft" : "published";
const res = await fetch(`/api/games/${game.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: nextStatus }),
});
if (!res.ok) {
setError("Не удалось изменить статус публикации");
return;
}
setGames((gs) => gs.map((g) => (g.id === game.id ? { ...g, status: nextStatus } : g)));
}
async function handleDelete(id: string) {
setError(null);
const res = await fetch(`/api/games/${id}`, { method: "DELETE" });
if (!res.ok) {
setError("Не удалось удалить игру");
return;
}
setGames((gs) => gs.filter((g) => g.id !== id));
}
if (games.length === 0) {
return <p className="text-sm text-text-muted">Вы ещё не создали ни одной игры.</p>;
}
return (
<div className="flex flex-col gap-3">
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{games.map((game) => (
<div key={game.id} className="card flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="badge">{TEMPLATE_LABELS[game.templateType]}</span>
<span
className={`badge ${
game.status === "published" ? "bg-success-soft text-success-soft-text" : "bg-surface-hover text-text-muted"
}`}
>
{game.status === "published" ? "Опубликовано" : "Черновик"}
</span>
</div>
<p className="truncate font-medium">{game.title}</p>
<p className="text-xs text-text-faint">{game.playCount} игр сыграно</p>
</div>
<Link href={`/play/${game.id}`} className="btn btn-ghost" title="Играть">
<Play size={14} />
</Link>
<Link href={`/my-games/${game.id}/edit`} className="btn btn-ghost" title="Редактировать">
<Pencil size={14} />
</Link>
<button
onClick={() => togglePublish(game)}
className="btn btn-ghost"
title={game.status === "published" ? "Снять с публикации" : "Опубликовать"}
>
{game.status === "published" ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button onClick={() => handleDelete(game.id)} className="btn btn-ghost text-danger" title="Удалить">
<Trash2 size={14} />
</button>
</div>
))}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { RotateCcw, Trophy, XCircle } from "lucide-react";
import { GameCanvas } from "./game-canvas";
import type { TemplateType, GameDefinition, GameResult } from "@/lib/games/types";
export function PlaySession({
templateType,
definition,
}: {
templateType: TemplateType;
definition: GameDefinition;
}) {
const [attempt, setAttempt] = useState(0);
const [result, setResult] = useState<GameResult | null>(null);
return (
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
<div className="relative">
<GameCanvas key={attempt} templateType={templateType} definition={definition} onFinish={setResult} />
{result && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/70 backdrop-blur-sm">
<div className="card flex flex-col items-center gap-3 p-8 text-center">
{result.won ? (
<Trophy size={32} className="text-accent" />
) : (
<XCircle size={32} className="text-danger" />
)}
<h2 className="font-display text-xl font-semibold">{result.won ? "Победа!" : "Игра окончена"}</h2>
{result.message && <p className="text-sm text-text-muted">{result.message}</p>}
<div className="mt-2 flex gap-2">
<button
onClick={() => {
setResult(null);
setAttempt((a) => a + 1);
}}
className="btn btn-primary"
>
<RotateCcw size={15} />
Играть снова
</button>
<Link href="/" className="btn btn-ghost">
В лаунчер
</Link>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import Anthropic from "@anthropic-ai/sdk";
/** Cheap validation call — used before persisting a new key/model, same "validate before save" shape as the mailbox/LDAP settings. */
export async function testAnthropicKey(apiKey: string, model: string): Promise<void> {
const client = new Anthropic({ apiKey });
await client.messages.create({
model,
max_tokens: 1,
messages: [{ role: "user", content: "ping" }],
});
}
+49
View File
@@ -0,0 +1,49 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { aiConfig } from "@/lib/db/schema";
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
export interface AiSettings {
apiKey: string;
model: string;
}
/** Settings for actually calling the API — null if never configured or disabled. */
export async function getAiSettings(): Promise<AiSettings | null> {
const config = await db.query.aiConfig.findFirst();
if (!config || !config.enabled) return null;
return { apiKey: decryptCredential(config.anthropicApiKeyEnc), model: config.model };
}
export async function saveAiSettings(settings: AiSettings): Promise<void> {
const row = {
anthropicApiKeyEnc: encryptCredential(settings.apiKey),
model: settings.model,
enabled: true,
verifiedAt: new Date(),
};
const existing = await db.query.aiConfig.findFirst();
if (existing) {
await db.update(aiConfig).set(row).where(eq(aiConfig.id, existing.id));
} else {
await db.insert(aiConfig).values(row);
}
}
export async function disableAi(): Promise<void> {
const existing = await db.query.aiConfig.findFirst();
if (existing) {
await db.update(aiConfig).set({ enabled: false }).where(eq(aiConfig.id, existing.id));
}
}
export async function getAiStatus() {
const config = await db.query.aiConfig.findFirst();
return {
configured: Boolean(config),
enabled: Boolean(config?.enabled),
model: config?.model ?? "claude-sonnet-5",
verifiedAt: config?.verifiedAt?.getTime() ?? null,
};
}
+50
View File
@@ -0,0 +1,50 @@
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { getAiSettings } from "./config";
import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/games/types";
const SYSTEM_PROMPTS: Record<TemplateType, string> = {
quiz: "Ты создаёшь викторины для простого игрового движка. Придумай вопросы и варианты ответа на русском языке по описанию пользователя. Один вариант в каждом вопросе должен быть правильным.",
clicker:
"Ты создаёшь простую кликер-игру (idle game) для игрового движка. Придумай тему, набор улучшений и целевой счёт по описанию пользователя. Улучшения должны быть сбалансированы: чем дороже — тем сильнее эффект.",
maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.",
};
/** Thrown when no Anthropic key is configured — callers map this to a clean "not configured" response. */
export class AiNotConfiguredError extends Error {
constructor() {
super("AI_NOT_CONFIGURED");
}
}
export async function generateGameDefinition(templateType: TemplateType, prompt: string): Promise<GameDefinition> {
const settings = await getAiSettings();
if (!settings) {
throw new AiNotConfiguredError();
}
const client = new Anthropic({ apiKey: settings.apiKey });
const schema = templateSchemas[templateType];
const outputFormat = zodOutputFormat(schema);
async function attempt(userContent: string): Promise<GameDefinition> {
const message = await client.messages.parse({
model: settings!.model,
max_tokens: 2048,
system: SYSTEM_PROMPTS[templateType],
messages: [{ role: "user", content: userContent }],
output_config: { format: outputFormat },
});
return message.parsed_output as GameDefinition;
}
try {
return await attempt(prompt);
} catch (err) {
// One retry, feeding the validation error back — covers the rare case
// where the model's first draft violates a refine() (e.g. an
// unreachable maze exit) despite the schema-constrained output.
const message = err instanceof Error ? err.message : String(err);
return await attempt(`${prompt}\n\nПредыдущая попытка не прошла проверку: ${message}. Исправь и верни заново.`);
}
}
+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "./session";
/** Guard for API route handlers — returns the session, or a 401 response to return as-is. */
export async function requireSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
+71
View File
@@ -0,0 +1,71 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
interface EncryptedPayload {
iv: string;
ciphertext: string;
authTag: string;
}
function loadKey(): Buffer {
const raw = process.env.CREDENTIALS_ENCRYPTION_KEY;
if (!raw) {
throw new Error(
"CREDENTIALS_ENCRYPTION_KEY is not set — generate one with `npm run generate-key`",
);
}
const key = Buffer.from(raw, "base64");
if (key.length !== 32) {
throw new Error("CREDENTIALS_ENCRYPTION_KEY must decode to exactly 32 bytes");
}
return key;
}
export function encryptCredential(plaintext: string, key: Buffer = loadKey()): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload: EncryptedPayload = {
iv: iv.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
return JSON.stringify(payload);
}
export function decryptCredential(encoded: string, key: Buffer = loadKey()): string {
const payload: EncryptedPayload = JSON.parse(encoded);
const iv = Buffer.from(payload.iv, "base64");
const ciphertext = Buffer.from(payload.ciphertext, "base64");
const authTag = Buffer.from(payload.authTag, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString("utf8");
}
+21
View File
@@ -0,0 +1,21 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import * as schema from "./schema";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
// Next's build-time page-data collection evaluates route modules across
// several worker processes concurrently — without this, two workers racing
// to open/initialize a fresh db.sqlite can throw SQLITE_BUSY instead of
// just waiting for the other's lock to clear (hit repeatedly in the
// sibling top-tickets project; baking the fix in from day one here).
sqlite.pragma("busy_timeout = 30000");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
@@ -0,0 +1,40 @@
CREATE TABLE `ai_config` (
`id` text PRIMARY KEY NOT NULL,
`anthropic_api_key_enc` text NOT NULL,
`model` text DEFAULT 'claude-sonnet-5' NOT NULL,
`enabled` integer DEFAULT false NOT NULL,
`verified_at` integer,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `games` (
`id` text PRIMARY KEY NOT NULL,
`owner_id` text NOT NULL,
`title` text NOT NULL,
`description` text,
`template_type` text NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`definition` text NOT NULL,
`play_count` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -0,0 +1,278 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ef845fe8-d266-400e-9a8c-264d90d11b26",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"ai_config": {
"name": "ai_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anthropic_api_key_enc": {
"name": "anthropic_api_key_enc",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claude-sonnet-5'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"verified_at": {
"name": "verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_type": {
"name": "template_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"games_owner_id_users_id_fk": {
"name": "games_owner_id_users_id_fk",
"tableFrom": "games",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785518228143,
"tag": "0000_marvelous_tyrannus",
"breakpoints": true
}
]
}
+63
View File
@@ -0,0 +1,63 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import crypto from "node:crypto";
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
};
/** Every registered account can both play and create games — no role split. */
export const users = sqliteTable("users", {
id: id(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
createdAt: timestamps.createdAt,
});
export const sessions = sqliteTable("sessions", {
// primary key is the SHA-256 hash of the raw session token — the raw
// token only ever lives in the client's httpOnly cookie.
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: timestamps.createdAt,
});
export const games = sqliteTable("games", {
id: id(),
ownerId: text("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
templateType: text("template_type", { enum: ["quiz", "clicker", "maze"] }).notNull(),
status: text("status", { enum: ["draft", "published"] }).notNull().default("draft"),
// JSON string validated against the templateType's zod schema (src/lib/games/*).
definition: text("definition").notNull(),
playCount: integer("play_count").notNull().default(0),
createdAt: timestamps.createdAt,
updatedAt: timestamps.updatedAt,
});
/** Singleton — same shape as top-tickets' telegramConfig/mailboxConfig. */
export const aiConfig = sqliteTable("ai_config", {
id: id(),
anthropicApiKeyEnc: text("anthropic_api_key_enc").notNull(),
model: text("model").notNull().default("claude-sonnet-5"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
export const clickerUpgradeSchema = z.object({
name: z.string().min(1).max(60),
cost: z.number().int().min(1),
pointsPerClickBonus: z.number().min(0).default(0),
pointsPerSecondBonus: z.number().min(0).default(0),
});
export const clickerDefinitionSchema = z.object({
targetLabel: z.string().min(1).max(60).default("Клик!"),
targetScore: z.number().int().min(10),
startingPointsPerClick: z.number().min(1).default(1),
upgrades: z.array(clickerUpgradeSchema).max(20).default([]),
});
export type ClickerUpgrade = z.infer<typeof clickerUpgradeSchema>;
export type ClickerDefinition = z.infer<typeof clickerDefinitionSchema>;
export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
targetLabel: "Монета",
targetScore: 100,
startingPointsPerClick: 1,
upgrades: [
{ name: "Крепкий клик", cost: 20, pointsPerClickBonus: 1, pointsPerSecondBonus: 0 },
{ name: "Автокликер", cost: 50, pointsPerClickBonus: 0, pointsPerSecondBonus: 1 },
],
};
+73
View File
@@ -0,0 +1,73 @@
import { z } from "zod";
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*"]);
/** BFS from S to any E, treating '#' as the only impassable tile. */
function isExitReachable(grid: string[]): boolean {
const rows = grid.length;
const cols = grid[0]?.length ?? 0;
let start: [number, number] | null = null;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === "S") start = [r, c];
}
}
if (!start) return false;
const visited = new Set<string>([`${start[0]},${start[1]}`]);
const queue: [number, number][] = [start];
while (queue.length > 0) {
const [r, c] = queue.shift()!;
if (grid[r][c] === "E") return true;
for (const [dr, dc] of [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const key = `${nr},${nc}`;
if (visited.has(key) || grid[nr][nc] === "#") continue;
visited.add(key);
queue.push([nr, nc]);
}
}
return false;
}
export const mazeDefinitionSchema = z
.object({
// Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit, '*' item.
grid: z.array(z.string().min(1)).min(3).max(30),
// 0 = no limit.
timeLimitSec: z.number().int().min(0).max(600).default(0),
})
.refine((def) => def.grid.every((row) => row.length === def.grid[0].length), {
message: "All grid rows must be the same length",
path: ["grid"],
})
.refine((def) => def.grid.every((row) => [...row].every((c) => MAZE_CHARS.has(c))), {
message: "Grid may only contain the characters # . S E *",
path: ["grid"],
})
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "S").length === 1, {
message: "Grid must contain exactly one S (start)",
path: ["grid"],
})
.refine((def) => def.grid.some((row) => row.includes("E")), {
message: "Grid must contain at least one E (exit)",
path: ["grid"],
})
.refine((def) => isExitReachable(def.grid), {
message: "Exit (E) must be reachable from the start (S)",
path: ["grid"],
});
export type MazeDefinition = z.infer<typeof mazeDefinitionSchema>;
export const DEFAULT_MAZE_DEFINITION: MazeDefinition = {
grid: ["#######", "#S.*..#", "#.###.#", "#...*.#", "#.###.#", "#....E#", "#######"],
timeLimitSec: 0,
};
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
export const quizQuestionSchema = z
.object({
question: z.string().min(1).max(300),
options: z.array(z.string().min(1).max(120)).min(2).max(6),
correctIndex: z.number().int().min(0),
})
.refine((q) => q.correctIndex < q.options.length, {
message: "correctIndex must be a valid index into options",
path: ["correctIndex"],
});
export const quizDefinitionSchema = z.object({
questions: z.array(quizQuestionSchema).min(1).max(30),
// 0 = no timer.
timePerQuestionSec: z.number().int().min(0).max(120).default(0),
});
export type QuizQuestion = z.infer<typeof quizQuestionSchema>;
export type QuizDefinition = z.infer<typeof quizDefinitionSchema>;
export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = {
questions: [{ question: "Сколько будет 2 + 2?", options: ["3", "4", "5"], correctIndex: 1 }],
timePerQuestionSec: 0,
};
+118
View File
@@ -0,0 +1,118 @@
import Phaser from "phaser";
import type { ClickerDefinition, ClickerUpgrade, GameResult } from "../types";
const COLORS = {
bg: 0x14162a,
target: 0x4f46e5,
row: 0x23264a,
rowOwned: 0x0d3d38,
rowDisabled: 0x1a1c33,
};
interface UpgradeRow {
bg: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
upgrade: ClickerUpgrade;
index: number;
}
export class ClickerScene extends Phaser.Scene {
private definition!: ClickerDefinition;
private onFinish?: (result: GameResult) => void;
private score = 0;
private pointsPerClick = 1;
private pointsPerSecond = 0;
private ownedUpgrades = new Set<number>();
private scoreText!: Phaser.GameObjects.Text;
private upgradeRows: UpgradeRow[] = [];
private finished = false;
constructor() {
super("ClickerScene");
}
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.score = 0;
this.pointsPerClick = this.definition.startingPointsPerClick;
this.pointsPerSecond = 0;
this.ownedUpgrades = new Set();
this.upgradeRows = [];
this.finished = false;
this.cameras.main.setBackgroundColor(COLORS.bg);
this.scoreText = this.add
.text(480, 30, "", { fontFamily: "sans-serif", fontSize: "24px", color: "#eef0fb", align: "center" })
.setOrigin(0.5, 0);
const target = this.add.circle(480, 200, 90, COLORS.target).setInteractive({ useHandCursor: true });
const targetLabel = this.add
.text(480, 200, this.definition.targetLabel, { fontFamily: "sans-serif", fontSize: "20px", color: "#ffffff" })
.setOrigin(0.5);
target.on("pointerdown", () => {
if (this.finished) return;
this.score += this.pointsPerClick;
this.tweens.add({ targets: [target, targetLabel], scale: 0.92, duration: 60, yoyo: true });
this.refresh();
});
this.definition.upgrades.forEach((upgrade, index) => {
const y = 340 + index * 54;
const bg = this.add
.rectangle(480, y, 700, 44, COLORS.row)
.setStrokeStyle(1, 0x4f46e5)
.setInteractive({ useHandCursor: true });
const label = this.add
.text(480, y, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" })
.setOrigin(0.5);
bg.on("pointerdown", () => this.buyUpgrade(index));
this.upgradeRows.push({ bg, label, upgrade, index });
});
this.time.addEvent({
delay: 1000,
loop: true,
callback: () => {
if (this.finished) return;
this.score += this.pointsPerSecond;
this.refresh();
},
});
this.refresh();
}
private buyUpgrade(index: number) {
if (this.finished || this.ownedUpgrades.has(index)) return;
const upgrade = this.definition.upgrades[index];
if (this.score < upgrade.cost) return;
this.score -= upgrade.cost;
this.pointsPerClick += upgrade.pointsPerClickBonus;
this.pointsPerSecond += upgrade.pointsPerSecondBonus;
this.ownedUpgrades.add(index);
this.refresh();
}
private refresh() {
this.scoreText.setText(
`${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`,
);
this.upgradeRows.forEach(({ bg, label, upgrade, index }) => {
const owned = this.ownedUpgrades.has(index);
const affordable = this.score >= upgrade.cost;
label.setText(owned ? `${upgrade.name} — куплено` : `${upgrade.name} — ${upgrade.cost}`);
bg.setFillStyle(owned ? COLORS.rowOwned : affordable ? COLORS.row : COLORS.rowDisabled);
bg.disableInteractive();
if (!owned && affordable) bg.setInteractive({ useHandCursor: true });
});
if (!this.finished && this.score >= this.definition.targetScore) {
this.finished = true;
this.onFinish?.({ won: true, score: Math.floor(this.score) });
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import type Phaser from "phaser";
import { QuizScene } from "./quiz-scene";
import { ClickerScene } from "./clicker-scene";
import { MazeScene } from "./maze-scene";
import type { TemplateType } from "../types";
export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene {
switch (templateType) {
case "quiz":
return QuizScene;
case "clicker":
return ClickerScene;
case "maze":
return MazeScene;
}
}
+166
View File
@@ -0,0 +1,166 @@
import Phaser from "phaser";
import type { MazeDefinition, GameResult } from "../types";
const COLORS = {
bg: 0x0a0b14,
wall: 0x292d4a,
floor: 0x1b1e36,
exit: 0x0d9488,
item: 0xfbbf24,
player: 0x4f46e5,
};
export class MazeScene extends Phaser.Scene {
private definition!: MazeDefinition;
private onFinish?: (result: GameResult) => void;
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private playerPos = { row: 0, col: 0 };
private items = new Set<string>();
private itemSprites = new Map<string, Phaser.GameObjects.Rectangle>();
private player!: Phaser.GameObjects.Arc;
private cellSize = 32;
private offsetX = 0;
private offsetY = 0;
private totalItems = 0;
private scoreText!: Phaser.GameObjects.Text;
private timerText?: Phaser.GameObjects.Text;
private remainingMs = 0;
private finished = false;
constructor() {
super("MazeScene");
}
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.finished = false;
this.cameras.main.setBackgroundColor(COLORS.bg);
const grid = this.definition.grid;
const rows = grid.length;
const cols = grid[0].length;
this.cellSize = Math.max(12, Math.min(44, Math.floor(880 / cols), Math.floor(460 / rows)));
this.offsetX = 480 - (cols * this.cellSize) / 2;
this.offsetY = 110;
this.items = new Set();
this.itemSprites = new Map();
this.totalItems = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ch = grid[r][c];
const x = this.offsetX + c * this.cellSize + this.cellSize / 2;
const y = this.offsetY + r * this.cellSize + this.cellSize / 2;
if (ch === "#") {
this.add.rectangle(x, y, this.cellSize - 1, this.cellSize - 1, COLORS.wall);
continue;
}
this.add.rectangle(x, y, this.cellSize - 1, this.cellSize - 1, ch === "E" ? COLORS.exit : COLORS.floor);
if (ch === "S") this.playerPos = { row: r, col: c };
if (ch === "*") {
const key = `${r},${c}`;
this.items.add(key);
this.totalItems += 1;
const item = this.add.rectangle(x, y, this.cellSize * 0.4, this.cellSize * 0.4, COLORS.item);
this.itemSprites.set(key, item);
}
}
}
const px = this.offsetX + this.playerPos.col * this.cellSize + this.cellSize / 2;
const py = this.offsetY + this.playerPos.row * this.cellSize + this.cellSize / 2;
this.player = this.add.circle(px, py, this.cellSize * 0.35, COLORS.player);
this.scoreText = this.add
.text(480, 40, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#eef0fb" })
.setOrigin(0.5, 0);
if (this.definition.timeLimitSec > 0) {
this.remainingMs = this.definition.timeLimitSec * 1000;
this.timerText = this.add
.text(480, 66, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#a3a8c6" })
.setOrigin(0.5, 0);
this.time.addEvent({
delay: 100,
loop: true,
callback: () => {
if (this.finished) return;
this.remainingMs -= 100;
if (this.remainingMs <= 0) {
this.remainingMs = 0;
this.finish(false, "Время вышло");
}
this.updateHud();
},
});
}
this.cursors = this.input.keyboard!.createCursorKeys();
this.updateHud();
}
update() {
if (this.finished) return;
const grid = this.definition.grid;
let { row, col } = this.playerPos;
let moved = false;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) {
col -= 1;
moved = true;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) {
col += 1;
moved = true;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) {
row -= 1;
moved = true;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) {
row += 1;
moved = true;
}
if (!moved) return;
if (row < 0 || row >= grid.length || col < 0 || col >= grid[0].length) return;
if (grid[row][col] === "#") return;
this.playerPos = { row, col };
this.player.x = this.offsetX + col * this.cellSize + this.cellSize / 2;
this.player.y = this.offsetY + row * this.cellSize + this.cellSize / 2;
const key = `${row},${col}`;
if (this.items.has(key)) {
this.items.delete(key);
this.itemSprites.get(key)?.destroy();
this.itemSprites.delete(key);
this.updateHud();
}
if (grid[row][col] === "E") {
this.finish(true);
} else {
this.updateHud();
}
}
private updateHud() {
const collected = this.totalItems - this.items.size;
this.scoreText.setText(`Собрано: ${collected}/${this.totalItems}`);
if (this.timerText) {
this.timerText.setText(`Осталось: ${Math.ceil(this.remainingMs / 1000)} сек`);
}
}
private finish(won: boolean, message?: string) {
this.finished = true;
const collected = this.totalItems - this.items.size;
this.onFinish?.({
won,
score: collected,
message: message ?? `Собрано: ${collected}/${this.totalItems}`,
});
}
}
+141
View File
@@ -0,0 +1,141 @@
import Phaser from "phaser";
import type { QuizDefinition, GameResult } from "../types";
const COLORS = {
bg: 0x14162a,
option: 0x23264a,
optionHover: 0x2d3163,
correct: 0x0d9488,
wrong: 0xdc2626,
timerTrack: 0x292d4a,
timerFill: 0x818cf8,
};
export class QuizScene extends Phaser.Scene {
private definition!: QuizDefinition;
private onFinish?: (result: GameResult) => void;
private questionIndex = 0;
private score = 0;
private locked = false;
private questionText!: Phaser.GameObjects.Text;
private optionRects: Phaser.GameObjects.Rectangle[] = [];
private optionLabels: Phaser.GameObjects.Text[] = [];
private timerBar?: Phaser.GameObjects.Rectangle;
private timerBarBg?: Phaser.GameObjects.Rectangle;
private timerEvent?: Phaser.Time.TimerEvent;
constructor() {
super("QuizScene");
}
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.questionIndex = 0;
this.score = 0;
this.cameras.main.setBackgroundColor(COLORS.bg);
this.questionText = this.add
.text(480, 60, "", {
fontFamily: "sans-serif",
fontSize: "24px",
color: "#eef0fb",
align: "center",
wordWrap: { width: 820 },
})
.setOrigin(0.5, 0);
this.showQuestion();
}
private clearOptions() {
this.optionRects.forEach((r) => r.destroy());
this.optionLabels.forEach((l) => l.destroy());
this.optionRects = [];
this.optionLabels = [];
this.timerEvent?.remove();
this.timerBar?.destroy();
this.timerBarBg?.destroy();
this.timerBar = undefined;
this.timerBarBg = undefined;
}
private showQuestion() {
this.locked = false;
this.clearOptions();
const q = this.definition.questions[this.questionIndex];
this.questionText.setText(`Вопрос ${this.questionIndex + 1}/${this.definition.questions.length}\n\n${q.question}`);
const startY = 230;
const gap = 70;
q.options.forEach((option, i) => {
const y = startY + i * gap;
const bg = this.add.rectangle(480, y, 700, 56, COLORS.option).setStrokeStyle(1, 0x4f46e5);
const label = this.add
.text(480, y, option, { fontFamily: "sans-serif", fontSize: "17px", color: "#eef0fb" })
.setOrigin(0.5);
bg.setInteractive({ useHandCursor: true });
bg.on("pointerover", () => {
if (!this.locked) bg.setFillStyle(COLORS.optionHover);
});
bg.on("pointerout", () => {
if (!this.locked) bg.setFillStyle(COLORS.option);
});
bg.on("pointerdown", () => this.selectOption(i, bg));
this.optionRects.push(bg);
this.optionLabels.push(label);
});
if (this.definition.timePerQuestionSec > 0) {
const barY = startY + q.options.length * gap + 20;
this.timerBarBg = this.add.rectangle(480, barY, 700, 8, COLORS.timerTrack);
this.timerBar = this.add.rectangle(480 - 350, barY, 700, 8, COLORS.timerFill).setOrigin(0, 0.5);
const totalMs = this.definition.timePerQuestionSec * 1000;
let elapsed = 0;
this.timerEvent = this.time.addEvent({
delay: 50,
loop: true,
callback: () => {
elapsed += 50;
const pct = Math.max(0, 1 - elapsed / totalMs);
this.timerBar?.setScale(pct, 1);
if (elapsed >= totalMs) {
this.timerEvent?.remove();
this.selectOption(-1);
}
},
});
}
}
private selectOption(index: number, bg?: Phaser.GameObjects.Rectangle) {
if (this.locked) return;
this.locked = true;
this.timerEvent?.remove();
const q = this.definition.questions[this.questionIndex];
const correct = index === q.correctIndex;
if (correct) this.score += 1;
if (bg) bg.setFillStyle(correct ? COLORS.correct : COLORS.wrong);
if (!correct && index >= 0) {
const correctBg = this.optionRects[q.correctIndex];
correctBg?.setFillStyle(COLORS.correct);
}
this.time.delayedCall(700, () => {
this.questionIndex += 1;
if (this.questionIndex >= this.definition.questions.length) {
const total = this.definition.questions.length;
this.onFinish?.({
won: this.score >= Math.ceil(total / 2),
score: this.score,
message: `${this.score} из ${total}`,
});
} else {
this.showQuestion();
}
});
}
}
+122
View File
@@ -0,0 +1,122 @@
import { eq, and, desc, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { games } from "@/lib/db/schema";
import { parseDefinition, type TemplateType, type GameDefinition } from "./types";
export interface GameSummaryDTO {
id: string;
ownerId: string;
title: string;
description: string | null;
templateType: TemplateType;
status: "draft" | "published";
playCount: number;
createdAt: number;
updatedAt: number;
}
export interface GameWithDefinitionDTO extends GameSummaryDTO {
definition: GameDefinition;
}
function toSummaryDTO(row: typeof games.$inferSelect): GameSummaryDTO {
return {
id: row.id,
ownerId: row.ownerId,
title: row.title,
description: row.description,
templateType: row.templateType,
status: row.status,
playCount: row.playCount,
createdAt: row.createdAt.getTime(),
updatedAt: row.updatedAt.getTime(),
};
}
function toWithDefinitionDTO(row: typeof games.$inferSelect): GameWithDefinitionDTO {
return {
...toSummaryDTO(row),
definition: parseDefinition(row.templateType, JSON.parse(row.definition)),
};
}
export async function listPublishedGames(): Promise<GameSummaryDTO[]> {
const rows = await db.query.games.findMany({
where: eq(games.status, "published"),
orderBy: desc(games.updatedAt),
});
return rows.map(toSummaryDTO);
}
export async function listGamesByOwner(ownerId: string): Promise<GameSummaryDTO[]> {
const rows = await db.query.games.findMany({
where: eq(games.ownerId, ownerId),
orderBy: desc(games.updatedAt),
});
return rows.map(toSummaryDTO);
}
export async function getGameById(id: string): Promise<GameWithDefinitionDTO | null> {
const row = await db.query.games.findFirst({ where: eq(games.id, id) });
return row ? toWithDefinitionDTO(row) : null;
}
export async function incrementPlayCount(id: string): Promise<void> {
await db
.update(games)
.set({ playCount: sql`${games.playCount} + 1` })
.where(eq(games.id, id));
}
export async function createGame(params: {
ownerId: string;
title: string;
description?: string;
templateType: TemplateType;
definition: GameDefinition;
}): Promise<GameSummaryDTO> {
const [row] = await db
.insert(games)
.values({
ownerId: params.ownerId,
title: params.title,
description: params.description ?? null,
templateType: params.templateType,
definition: JSON.stringify(params.definition),
status: "draft",
})
.returning();
return toSummaryDTO(row);
}
export async function updateGame(
id: string,
ownerId: string,
patch: Partial<{
title: string;
description: string | null;
definition: GameDefinition;
status: "draft" | "published";
}>,
): Promise<GameSummaryDTO | null> {
const values: Partial<typeof games.$inferInsert> = { updatedAt: new Date() };
if (patch.title !== undefined) values.title = patch.title;
if (patch.description !== undefined) values.description = patch.description;
if (patch.definition !== undefined) values.definition = JSON.stringify(patch.definition);
if (patch.status !== undefined) values.status = patch.status;
const [row] = await db
.update(games)
.set(values)
.where(and(eq(games.id, id), eq(games.ownerId, ownerId)))
.returning();
return row ? toSummaryDTO(row) : null;
}
export async function deleteGame(id: string, ownerId: string): Promise<boolean> {
const rows = await db
.delete(games)
.where(and(eq(games.id, id), eq(games.ownerId, ownerId)))
.returning({ id: games.id });
return rows.length > 0;
}
+55
View File
@@ -0,0 +1,55 @@
import { quizDefinitionSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
import { clickerDefinitionSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
import { mazeDefinitionSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const;
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
export const TEMPLATE_LABELS: Record<TemplateType, string> = {
quiz: "Викторина",
clicker: "Кликер",
maze: "Лабиринт",
};
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
quiz: "Вопросы с вариантами ответа, подсчёт очков.",
clicker: "Кликай, зарабатывай очки, покупай улучшения.",
maze: "Собери предметы и дойди до выхода по лабиринту.",
};
export const templateSchemas = {
quiz: quizDefinitionSchema,
clicker: clickerDefinitionSchema,
maze: mazeDefinitionSchema,
};
export const TEMPLATE_DEFAULTS = {
quiz: DEFAULT_QUIZ_DEFINITION,
clicker: DEFAULT_CLICKER_DEFINITION,
maze: DEFAULT_MAZE_DEFINITION,
};
export function parseDefinition(templateType: TemplateType, data: unknown) {
return templateSchemas[templateType].parse(data);
}
export function safeParseDefinition(templateType: TemplateType, data: unknown) {
return templateSchemas[templateType].safeParse(data);
}
/** What a template Scene reports back through the registry callback when the game ends. */
export interface GameResult {
won: boolean;
score: number;
message?: string;
}
export type { QuizDefinition, QuizQuestion } from "./quiz";
export type { ClickerDefinition, ClickerUpgrade } from "./clicker";
export type { MazeDefinition } from "./maze";
import type { QuizDefinition } from "./quiz";
import type { ClickerDefinition } from "./clicker";
import type { MazeDefinition } from "./maze";
export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition;