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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user