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:
1 parent
df16c90832
commit
c9a6c27a7a
60 files changed
+5601
-93
No files matched your search
@@ -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
|
||||
@@ -32,6 +32,10 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# app data (SQLite db, WAL files)
|
||||
/data/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
+22
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
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;
|
||||
Generated
+2095
-13
File diff suppressed because it is too large.
Load diff
+16
-3
@@ -5,22 +5,35 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"start": "next start -p ${PORT:-8082}",
|
||||
"lint": "eslint",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"generate-key": "tsx scripts/generate-key.ts"
|
||||
},
|
||||
"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",
|
||||
"phaser": "^4.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,26 +1,171 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
@theme {
|
||||
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
:root {
|
||||
--bg: #f7f8fb;
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #eef1fa;
|
||||
--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) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--bg: #0a0b14;
|
||||
--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 {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
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
@@ -1,20 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
subsets: ["latin", "cyrillic"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
subsets: ["latin", "cyrillic"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "tis — топ-игроструктор",
|
||||
description: "Лаунчер и конструктор браузерных игр",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +25,13 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="ru"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
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 className="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Лаунчер</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">Играйте в игры, которые сделали другие пользователи.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
<Link href={session ? "/create" : "/register"} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Создать игру
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div className="card flex flex-col items-center gap-3 p-12 text-center">
|
||||
<Gamepad2 size={32} className="text-text-faint" />
|
||||
<p className="text-text-muted">Пока нет опубликованных игр — будьте первым, кто её создаст.</p>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}. Исправь и верни заново.`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1785518228143,
|
||||
"tag": "0000_marvelous_tyrannus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user