Add a 4th template: classic Snake
New templateType "snake" alongside quiz/clicker/maze — additive, not a redesign, so it's wired through every existing per-template enumeration point (schema, types unions, scene factory, builder dispatch on create/edit/AI-review pages, template icon maps) following the pattern each already established. games.templateType is a plain SQLite text column with a TypeScript- only enum (no SQL DDL behind it) — confirmed via drizzle-kit generate that adding "snake" produces zero migration, same as every in-place template redesign this session. Bounded arena (no maze-style wall authoring — keeps the builder trivial: width/height/startLength/targetLength/wrapAround, nothing else). Movement is a self-rescheduling delayedCall loop rather than a fixed interval, since the step interval itself shrinks as the snake grows; speed curve sanity-checked standalone before wiring it in. Discrete per-cell snapping (not tweened, unlike the maze's glide) — that's the authentic blocky Snake feel, not a shortcut. Standard collision rule: the tail cell is excluded from the self-collision check unless this step eats food, since the tail is vacating that cell. Progress resumes the exact live state (body/direction/food/score) — Snake has no natural level-boundary checkpoint the way the other three templates do, so unlike their coarse-grained resume this restores precisely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
c035650063
commit
a61ef772db
12 files changed
+477
-6
No files matched your search
@@ -4,6 +4,7 @@ import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType } from "@/lib/games/
|
|||||||
import { QuizBuilder } from "@/components/builders/quiz-builder";
|
import { QuizBuilder } from "@/components/builders/quiz-builder";
|
||||||
import { ClickerBuilder } from "@/components/builders/clicker-builder";
|
import { ClickerBuilder } from "@/components/builders/clicker-builder";
|
||||||
import { MazeBuilder } from "@/components/builders/maze-builder";
|
import { MazeBuilder } from "@/components/builders/maze-builder";
|
||||||
|
import { SnakeBuilder } from "@/components/builders/snake-builder";
|
||||||
|
|
||||||
export default async function ManualCreatePage({ params }: { params: Promise<{ template: string }> }) {
|
export default async function ManualCreatePage({ params }: { params: Promise<{ template: string }> }) {
|
||||||
const { template } = await params;
|
const { template } = await params;
|
||||||
@@ -23,6 +24,7 @@ export default async function ManualCreatePage({ params }: { params: Promise<{ t
|
|||||||
{templateType === "quiz" && <QuizBuilder mode="create" />}
|
{templateType === "quiz" && <QuizBuilder mode="create" />}
|
||||||
{templateType === "clicker" && <ClickerBuilder mode="create" />}
|
{templateType === "clicker" && <ClickerBuilder mode="create" />}
|
||||||
{templateType === "maze" && <MazeBuilder mode="create" />}
|
{templateType === "maze" && <MazeBuilder mode="create" />}
|
||||||
|
{templateType === "snake" && <SnakeBuilder mode="create" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { HelpCircle, MousePointerClick, Map as MapIcon, Sparkles } from "lucide-react";
|
import { HelpCircle, MousePointerClick, Map as MapIcon, Worm, Sparkles } from "lucide-react";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { TEMPLATE_TYPES, TEMPLATE_LABELS, TEMPLATE_DESCRIPTIONS, type TemplateType } from "@/lib/games/types";
|
import { TEMPLATE_TYPES, TEMPLATE_LABELS, TEMPLATE_DESCRIPTIONS, type TemplateType } from "@/lib/games/types";
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
|
|||||||
quiz: HelpCircle,
|
quiz: HelpCircle,
|
||||||
clicker: MousePointerClick,
|
clicker: MousePointerClick,
|
||||||
maze: MapIcon,
|
maze: MapIcon,
|
||||||
|
snake: Worm,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function CreatePage() {
|
export default async function CreatePage() {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { TEMPLATE_LABELS } from "@/lib/games/types";
|
|||||||
import { QuizBuilder } from "@/components/builders/quiz-builder";
|
import { QuizBuilder } from "@/components/builders/quiz-builder";
|
||||||
import { ClickerBuilder } from "@/components/builders/clicker-builder";
|
import { ClickerBuilder } from "@/components/builders/clicker-builder";
|
||||||
import { MazeBuilder } from "@/components/builders/maze-builder";
|
import { MazeBuilder } from "@/components/builders/maze-builder";
|
||||||
|
import { SnakeBuilder } from "@/components/builders/snake-builder";
|
||||||
import type { QuizDefinition } from "@/lib/games/quiz";
|
import type { QuizDefinition } from "@/lib/games/quiz";
|
||||||
import type { ClickerDefinition } from "@/lib/games/clicker";
|
import type { ClickerDefinition } from "@/lib/games/clicker";
|
||||||
import type { MazeDefinition } from "@/lib/games/maze";
|
import type { MazeDefinition } from "@/lib/games/maze";
|
||||||
|
import type { SnakeDefinition } from "@/lib/games/snake";
|
||||||
|
|
||||||
export default async function EditGamePage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function EditGamePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -59,6 +61,15 @@ export default async function EditGamePage({ params }: { params: Promise<{ id: s
|
|||||||
initialDefinition={game.definition as MazeDefinition}
|
initialDefinition={game.definition as MazeDefinition}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{game.templateType === "snake" && (
|
||||||
|
<SnakeBuilder
|
||||||
|
mode="edit"
|
||||||
|
gameId={game.id}
|
||||||
|
initialTitle={game.title}
|
||||||
|
initialDescription={game.description ?? ""}
|
||||||
|
initialDefinition={game.definition as SnakeDefinition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,11 @@ import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType, type GameDefinition
|
|||||||
import type { QuizDefinition } from "@/lib/games/quiz";
|
import type { QuizDefinition } from "@/lib/games/quiz";
|
||||||
import type { ClickerDefinition } from "@/lib/games/clicker";
|
import type { ClickerDefinition } from "@/lib/games/clicker";
|
||||||
import type { MazeDefinition } from "@/lib/games/maze";
|
import type { MazeDefinition } from "@/lib/games/maze";
|
||||||
|
import type { SnakeDefinition } from "@/lib/games/snake";
|
||||||
import { QuizBuilder } from "@/components/builders/quiz-builder";
|
import { QuizBuilder } from "@/components/builders/quiz-builder";
|
||||||
import { ClickerBuilder } from "@/components/builders/clicker-builder";
|
import { ClickerBuilder } from "@/components/builders/clicker-builder";
|
||||||
import { MazeBuilder } from "@/components/builders/maze-builder";
|
import { MazeBuilder } from "@/components/builders/maze-builder";
|
||||||
|
import { SnakeBuilder } from "@/components/builders/snake-builder";
|
||||||
|
|
||||||
export function AiGenerateForm() {
|
export function AiGenerateForm() {
|
||||||
const [templateType, setTemplateType] = useState<TemplateType>("quiz");
|
const [templateType, setTemplateType] = useState<TemplateType>("quiz");
|
||||||
@@ -56,6 +58,9 @@ export function AiGenerateForm() {
|
|||||||
{templateType === "maze" && (
|
{templateType === "maze" && (
|
||||||
<MazeBuilder mode="create" initialTitle={title} initialDefinition={definition as MazeDefinition} />
|
<MazeBuilder mode="create" initialTitle={title} initialDefinition={definition as MazeDefinition} />
|
||||||
)}
|
)}
|
||||||
|
{templateType === "snake" && (
|
||||||
|
<SnakeBuilder mode="create" initialTitle={title} initialDefinition={definition as SnakeDefinition} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { snakeDefinitionSchema, DEFAULT_SNAKE_DEFINITION, type SnakeDefinition } from "@/lib/games/snake";
|
||||||
|
|
||||||
|
export function SnakeBuilder({
|
||||||
|
mode,
|
||||||
|
gameId,
|
||||||
|
initialTitle,
|
||||||
|
initialDescription,
|
||||||
|
initialDefinition,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
gameId?: string;
|
||||||
|
initialTitle?: string;
|
||||||
|
initialDescription?: string;
|
||||||
|
initialDefinition?: SnakeDefinition;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState(initialTitle ?? "");
|
||||||
|
const [description, setDescription] = useState(initialDescription ?? "");
|
||||||
|
const [width, setWidth] = useState(initialDefinition?.width ?? DEFAULT_SNAKE_DEFINITION.width);
|
||||||
|
const [height, setHeight] = useState(initialDefinition?.height ?? DEFAULT_SNAKE_DEFINITION.height);
|
||||||
|
const [startLength, setStartLength] = useState(initialDefinition?.startLength ?? DEFAULT_SNAKE_DEFINITION.startLength);
|
||||||
|
const [targetLength, setTargetLength] = useState(
|
||||||
|
initialDefinition?.targetLength ?? DEFAULT_SNAKE_DEFINITION.targetLength,
|
||||||
|
);
|
||||||
|
const [wrapAround, setWrapAround] = useState(initialDefinition?.wrapAround ?? DEFAULT_SNAKE_DEFINITION.wrapAround);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setError(null);
|
||||||
|
const definition: SnakeDefinition = { width, height, startLength, targetLength, wrapAround };
|
||||||
|
const parsed = snakeDefinitionSchema.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: "snake", 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-2 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Ширина поля (8-40)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={40}
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => setWidth(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">Высота поля (8-40)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={40}
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => setHeight(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}
|
||||||
|
max={10}
|
||||||
|
value={startLength}
|
||||||
|
onChange={(e) => setStartLength(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={5}
|
||||||
|
max={200}
|
||||||
|
value={targetLength}
|
||||||
|
onChange={(e) => setTargetLength(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>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-text-muted">
|
||||||
|
<input type="checkbox" checked={wrapAround} onChange={(e) => setWrapAround(e.target.checked)} />
|
||||||
|
Проходить сквозь стены (выход с одного края — вход с другого)
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-text-faint">
|
||||||
|
Скорость растёт по мере роста змейки автоматически. Длина для победы не может превышать ширину × высоту.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { HelpCircle, MousePointerClick, Map as MapIcon, Play } from "lucide-react";
|
import { HelpCircle, MousePointerClick, Map as MapIcon, Worm, Play } from "lucide-react";
|
||||||
import { TEMPLATE_LABELS, type TemplateType } from "@/lib/games/types";
|
import { TEMPLATE_LABELS, type TemplateType } from "@/lib/games/types";
|
||||||
|
|
||||||
const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
|
const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
|
||||||
quiz: HelpCircle,
|
quiz: HelpCircle,
|
||||||
clicker: MousePointerClick,
|
clicker: MousePointerClick,
|
||||||
maze: MapIcon,
|
maze: MapIcon,
|
||||||
|
snake: Worm,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GameCard({
|
export function GameCard({
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const SYSTEM_PROMPTS: Record<TemplateType, string> = {
|
|||||||
clicker:
|
clicker:
|
||||||
"Ты создаёшь RPG-кликер в духе Clicker Heroes для игрового движка: игрок бьёт монстра по клику, монстр имеет HP, каждые 5 уровней — босс с таймером. Придумай тему (theme), название босса через bossEmoji (один эмодзи), пул из 5-10 эмодзи обычных монстров (monsterEmojis, каждый — один эмодзи-символ) и 4-8 героев (heroes) по описанию пользователя — герои покупаются многократно (уровни), у каждого либо бонус к урону за клик (clickDamageBonus), либо к урону в секунду (dpsBonus), либо оба понемногу. Более поздние герои в списке должны быть дороже (baseCost) и сильнее.",
|
"Ты создаёшь RPG-кликер в духе Clicker Heroes для игрового движка: игрок бьёт монстра по клику, монстр имеет HP, каждые 5 уровней — босс с таймером. Придумай тему (theme), название босса через bossEmoji (один эмодзи), пул из 5-10 эмодзи обычных монстров (monsterEmojis, каждый — один эмодзи-символ) и 4-8 героев (heroes) по описанию пользователя — герои покупаются многократно (уровни), у каждого либо бонус к урону за клик (clickDamageBonus), либо к урону в секунду (dpsBonus), либо оба понемногу. Более поздние герои в списке должны быть дороже (baseCost) и сильнее.",
|
||||||
maze: "Ты создаёшь лабиринт в стиле Pac-Man для игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Символы: # стена, . пол, S старт (ровно один), E выход (хотя бы один), * обычная точка, O усиливающая точка (пугает привидений на время), G призрак-респавн (0-4 штук). Расставь точки * почти на каждой проходимой клетке коридоров (как в настоящем Pac-Man), несколько O в дальних углах. Победа — собрать все * и O, затем дойти до E, поэтому каждая точка, усиливающая точка и выход обязательно должны быть достижимы от старта по коридорам без стен.",
|
maze: "Ты создаёшь лабиринт в стиле Pac-Man для игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Символы: # стена, . пол, S старт (ровно один), E выход (хотя бы один), * обычная точка, O усиливающая точка (пугает привидений на время), G призрак-респавн (0-4 штук). Расставь точки * почти на каждой проходимой клетке коридоров (как в настоящем Pac-Man), несколько O в дальних углах. Победа — собрать все * и O, затем дойти до E, поэтому каждая точка, усиливающая точка и выход обязательно должны быть достижимы от старта по коридорам без стен.",
|
||||||
|
snake:
|
||||||
|
"Ты настраиваешь классическую змейку по описанию пользователя. Поля: width и height (8-40, размер поля), startLength (1-10, начальная длина змейки), targetLength (5-200, длина для победы — должна помещаться в поле, то есть не больше width×height), wrapAround (проходить сквозь стены — true/false). Никаких текстовых полей с темой или сюжетом не требуется, просто подбери сбалансированные числовые параметры под описание пользователя (например, «маленькое быстрое поле» — меньше width/height, «долгая игра» — больше targetLength).",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Thrown when no Anthropic key is configured — callers map this to a clean "not configured" response. */
|
/** Thrown when no Anthropic key is configured — callers map this to a clean "not configured" response. */
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const games = sqliteTable("games", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
templateType: text("template_type", { enum: ["quiz", "clicker", "maze"] }).notNull(),
|
templateType: text("template_type", { enum: ["quiz", "clicker", "maze", "snake"] }).notNull(),
|
||||||
status: text("status", { enum: ["draft", "published"] }).notNull().default("draft"),
|
status: text("status", { enum: ["draft", "published"] }).notNull().default("draft"),
|
||||||
// JSON string validated against the templateType's zod schema (src/lib/games/*).
|
// JSON string validated against the templateType's zod schema (src/lib/games/*).
|
||||||
definition: text("definition").notNull(),
|
definition: text("definition").notNull(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type Phaser from "phaser";
|
|||||||
import { QuizScene } from "./quiz-scene";
|
import { QuizScene } from "./quiz-scene";
|
||||||
import { ClickerScene } from "./clicker-scene";
|
import { ClickerScene } from "./clicker-scene";
|
||||||
import { MazeScene } from "./maze-scene";
|
import { MazeScene } from "./maze-scene";
|
||||||
|
import { SnakeScene } from "./snake-scene";
|
||||||
import type { TemplateType } from "../types";
|
import type { TemplateType } from "../types";
|
||||||
|
|
||||||
export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene {
|
export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene {
|
||||||
@@ -12,5 +13,7 @@ export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene {
|
|||||||
return ClickerScene;
|
return ClickerScene;
|
||||||
case "maze":
|
case "maze":
|
||||||
return MazeScene;
|
return MazeScene;
|
||||||
|
case "snake":
|
||||||
|
return SnakeScene;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import { stepMsForLength, FOOD_SCORE } from "../snake";
|
||||||
|
import type { SnakeDefinition, SnakeProgress, GameResult } from "../types";
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
bg: 0x0a0b14,
|
||||||
|
border: 0x292d4a,
|
||||||
|
head: 0x34d399,
|
||||||
|
body: 0x10b981,
|
||||||
|
food: 0xef4444,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Direction = "up" | "down" | "left" | "right";
|
||||||
|
type Cell = { row: number; col: number };
|
||||||
|
|
||||||
|
const DIR_DELTA: Record<Direction, { dr: number; dc: number }> = {
|
||||||
|
up: { dr: -1, dc: 0 },
|
||||||
|
down: { dr: 1, dc: 0 },
|
||||||
|
left: { dr: 0, dc: -1 },
|
||||||
|
right: { dr: 0, dc: 1 },
|
||||||
|
};
|
||||||
|
const OPPOSITE: Record<Direction, Direction> = { up: "down", down: "up", left: "right", right: "left" };
|
||||||
|
|
||||||
|
export class SnakeScene extends Phaser.Scene {
|
||||||
|
private definition!: SnakeDefinition;
|
||||||
|
private onFinish?: (result: GameResult) => void;
|
||||||
|
private onProgress?: (state: SnakeProgress) => void;
|
||||||
|
|
||||||
|
private width = 20;
|
||||||
|
private height = 15;
|
||||||
|
private cellSize = 20;
|
||||||
|
private offsetX = 0;
|
||||||
|
private offsetY = 0;
|
||||||
|
|
||||||
|
private body: Cell[] = [];
|
||||||
|
private direction: Direction = "right";
|
||||||
|
private desiredDirection: Direction = "right";
|
||||||
|
private food: Cell = { row: 0, col: 0 };
|
||||||
|
private score = 0;
|
||||||
|
private finished = false;
|
||||||
|
|
||||||
|
private bodyRects: Phaser.GameObjects.Rectangle[] = [];
|
||||||
|
private foodShape!: Phaser.GameObjects.Arc;
|
||||||
|
private scoreText!: Phaser.GameObjects.Text;
|
||||||
|
private lengthText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("SnakeScene");
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.definition = this.registry.get("definition");
|
||||||
|
this.onFinish = this.registry.get("onFinish");
|
||||||
|
this.onProgress = this.registry.get("onProgress");
|
||||||
|
this.finished = false;
|
||||||
|
|
||||||
|
this.cameras.main.setBackgroundColor(COLORS.bg);
|
||||||
|
|
||||||
|
this.width = this.definition.width;
|
||||||
|
this.height = this.definition.height;
|
||||||
|
this.cellSize = Math.max(8, Math.min(30, Math.floor(920 / this.width), Math.floor(460 / this.height)));
|
||||||
|
this.offsetX = 480 - (this.width * this.cellSize) / 2;
|
||||||
|
this.offsetY = 110;
|
||||||
|
|
||||||
|
this.add
|
||||||
|
.rectangle(
|
||||||
|
480,
|
||||||
|
this.offsetY + (this.height * this.cellSize) / 2,
|
||||||
|
this.width * this.cellSize + 4,
|
||||||
|
this.height * this.cellSize + 4,
|
||||||
|
)
|
||||||
|
.setStrokeStyle(2, COLORS.border);
|
||||||
|
|
||||||
|
const initialState = this.registry.get("initialState") as SnakeProgress | undefined;
|
||||||
|
if (initialState && this.isValidState(initialState)) {
|
||||||
|
this.body = initialState.body.map((c) => ({ ...c }));
|
||||||
|
this.direction = initialState.direction;
|
||||||
|
this.food = { ...initialState.food };
|
||||||
|
this.score = initialState.score;
|
||||||
|
} else {
|
||||||
|
const startRow = Math.floor(this.height / 2);
|
||||||
|
const startCol = Math.floor(this.width / 2);
|
||||||
|
this.body = Array.from({ length: this.definition.startLength }, (_, i) => ({
|
||||||
|
row: startRow,
|
||||||
|
col: startCol - i,
|
||||||
|
}));
|
||||||
|
this.direction = "right";
|
||||||
|
this.score = 0;
|
||||||
|
this.food = this.randomEmptyCell();
|
||||||
|
}
|
||||||
|
this.desiredDirection = this.direction;
|
||||||
|
|
||||||
|
this.bodyRects = [];
|
||||||
|
this.redrawBody();
|
||||||
|
this.foodShape = this.add.circle(0, 0, this.cellSize * 0.4, COLORS.food);
|
||||||
|
this.updateFoodPosition();
|
||||||
|
|
||||||
|
this.scoreText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" });
|
||||||
|
this.lengthText = this.add
|
||||||
|
.text(940, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" })
|
||||||
|
.setOrigin(1, 0);
|
||||||
|
|
||||||
|
this.input.keyboard!.on("keydown-LEFT", () => this.setDesiredDirection("left"));
|
||||||
|
this.input.keyboard!.on("keydown-RIGHT", () => this.setDesiredDirection("right"));
|
||||||
|
this.input.keyboard!.on("keydown-UP", () => this.setDesiredDirection("up"));
|
||||||
|
this.input.keyboard!.on("keydown-DOWN", () => this.setDesiredDirection("down"));
|
||||||
|
|
||||||
|
this.updateHud();
|
||||||
|
this.reportProgress();
|
||||||
|
this.scheduleNextStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setDesiredDirection(dir: Direction) {
|
||||||
|
if (OPPOSITE[dir] === this.direction && this.body.length > 1) return;
|
||||||
|
this.desiredDirection = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextStep() {
|
||||||
|
if (this.finished) return;
|
||||||
|
const stepMs = stepMsForLength(this.body.length, this.definition.startLength);
|
||||||
|
this.time.delayedCall(stepMs, () => {
|
||||||
|
this.step();
|
||||||
|
this.scheduleNextStep();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private step() {
|
||||||
|
if (this.finished) return;
|
||||||
|
this.direction = this.desiredDirection;
|
||||||
|
|
||||||
|
const delta = DIR_DELTA[this.direction];
|
||||||
|
const head = this.body[0];
|
||||||
|
let nr = head.row + delta.dr;
|
||||||
|
let nc = head.col + delta.dc;
|
||||||
|
|
||||||
|
if (this.definition.wrapAround) {
|
||||||
|
nr = (nr + this.height) % this.height;
|
||||||
|
nc = (nc + this.width) % this.width;
|
||||||
|
} else if (nr < 0 || nr >= this.height || nc < 0 || nc >= this.width) {
|
||||||
|
this.finish(false, this.score, `Врезались в стену. Очков: ${this.score}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ateFood = nr === this.food.row && nc === this.food.col;
|
||||||
|
const bodyToCheck = ateFood ? this.body : this.body.slice(0, -1);
|
||||||
|
if (bodyToCheck.some((seg) => seg.row === nr && seg.col === nc)) {
|
||||||
|
this.finish(false, this.score, `Врезались в себя. Очков: ${this.score}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.body.unshift({ row: nr, col: nc });
|
||||||
|
|
||||||
|
if (ateFood) {
|
||||||
|
this.score += FOOD_SCORE;
|
||||||
|
if (this.body.length >= this.definition.targetLength) {
|
||||||
|
this.redrawBody();
|
||||||
|
this.finish(true, this.score, `Змейка выросла до ${this.body.length}! Очков: ${this.score}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.food = this.randomEmptyCell();
|
||||||
|
this.updateFoodPosition();
|
||||||
|
} else {
|
||||||
|
this.body.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.redrawBody();
|
||||||
|
this.updateHud();
|
||||||
|
this.reportProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidState(s: SnakeProgress): boolean {
|
||||||
|
const inBounds = (c: Cell) => c.row >= 0 && c.row < this.height && c.col >= 0 && c.col < this.width;
|
||||||
|
return s.body.every(inBounds) && inBounds(s.food);
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomEmptyCell(): Cell {
|
||||||
|
const occupied = new Set(this.body.map((c) => `${c.row},${c.col}`));
|
||||||
|
const free: Cell[] = [];
|
||||||
|
for (let r = 0; r < this.height; r++) {
|
||||||
|
for (let c = 0; c < this.width; c++) {
|
||||||
|
if (!occupied.has(`${r},${c}`)) free.push({ row: r, col: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return free.length > 0 ? free[Math.floor(Math.random() * free.length)] : this.body[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private cellToPixel(cell: Cell): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: this.offsetX + cell.col * this.cellSize + this.cellSize / 2,
|
||||||
|
y: this.offsetY + cell.row * this.cellSize + this.cellSize / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private redrawBody() {
|
||||||
|
while (this.bodyRects.length < this.body.length) {
|
||||||
|
this.bodyRects.push(this.add.rectangle(0, 0, this.cellSize - 2, this.cellSize - 2, COLORS.body));
|
||||||
|
}
|
||||||
|
while (this.bodyRects.length > this.body.length) {
|
||||||
|
this.bodyRects.pop()?.destroy();
|
||||||
|
}
|
||||||
|
this.body.forEach((seg, i) => {
|
||||||
|
const { x, y } = this.cellToPixel(seg);
|
||||||
|
this.bodyRects[i].setPosition(x, y);
|
||||||
|
this.bodyRects[i].setFillStyle(i === 0 ? COLORS.head : COLORS.body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFoodPosition() {
|
||||||
|
const { x, y } = this.cellToPixel(this.food);
|
||||||
|
this.foodShape.setPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHud() {
|
||||||
|
this.scoreText.setText(`Очки: ${this.score}`);
|
||||||
|
this.lengthText.setText(`Длина: ${this.body.length}/${this.definition.targetLength}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportProgress() {
|
||||||
|
this.onProgress?.({
|
||||||
|
body: this.body.map((c) => ({ ...c })),
|
||||||
|
direction: this.direction,
|
||||||
|
food: { ...this.food },
|
||||||
|
score: this.score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(won: boolean, score: number, message: string) {
|
||||||
|
this.finished = true;
|
||||||
|
this.onFinish?.({ won, score, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Fixed speed curve — not hand-authored (same "curve is fixed" precedent as
|
||||||
|
// the quiz money ladder, clicker HP curve, and maze movement timing).
|
||||||
|
export const BASE_STEP_MS = 160;
|
||||||
|
export const MIN_STEP_MS = 70;
|
||||||
|
export const STEP_MS_DECREASE_PER_FOOD = 4;
|
||||||
|
export const FOOD_SCORE = 10;
|
||||||
|
|
||||||
|
export function stepMsForLength(length: number, startLength: number): number {
|
||||||
|
const eaten = Math.max(0, length - startLength);
|
||||||
|
return Math.max(MIN_STEP_MS, BASE_STEP_MS - eaten * STEP_MS_DECREASE_PER_FOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snakeDefinitionSchema = z
|
||||||
|
.object({
|
||||||
|
width: z.number().int().min(8).max(40).default(20),
|
||||||
|
height: z.number().int().min(8).max(40).default(15),
|
||||||
|
startLength: z.number().int().min(1).max(10).default(3),
|
||||||
|
targetLength: z.number().int().min(5).max(200).default(20),
|
||||||
|
wrapAround: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.refine((def) => def.targetLength <= def.width * def.height, {
|
||||||
|
message: "targetLength must fit within the arena (width × height)",
|
||||||
|
path: ["targetLength"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SnakeDefinition = z.infer<typeof snakeDefinitionSchema>;
|
||||||
|
|
||||||
|
export const DEFAULT_SNAKE_DEFINITION: SnakeDefinition = {
|
||||||
|
width: 20,
|
||||||
|
height: 15,
|
||||||
|
startLength: 3,
|
||||||
|
targetLength: 20,
|
||||||
|
wrapAround: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cellSchema = z.object({ row: z.number().int().min(0), col: z.number().int().min(0) });
|
||||||
|
|
||||||
|
export const snakeProgressSchema = z.object({
|
||||||
|
body: z.array(cellSchema).min(1),
|
||||||
|
direction: z.enum(["up", "down", "left", "right"]),
|
||||||
|
food: cellSchema,
|
||||||
|
score: z.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SnakeProgress = z.infer<typeof snakeProgressSchema>;
|
||||||
+11
-3
@@ -1,32 +1,37 @@
|
|||||||
import { quizDefinitionSchema, quizProgressSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
|
import { quizDefinitionSchema, quizProgressSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
|
||||||
import { clickerDefinitionSchema, clickerProgressSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
|
import { clickerDefinitionSchema, clickerProgressSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
|
||||||
import { mazeDefinitionSchema, mazeProgressSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
|
import { mazeDefinitionSchema, mazeProgressSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
|
||||||
|
import { snakeDefinitionSchema, snakeProgressSchema, DEFAULT_SNAKE_DEFINITION } from "./snake";
|
||||||
|
|
||||||
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const;
|
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze", "snake"] as const;
|
||||||
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
||||||
|
|
||||||
export const TEMPLATE_LABELS: Record<TemplateType, string> = {
|
export const TEMPLATE_LABELS: Record<TemplateType, string> = {
|
||||||
quiz: "Кто хочет стать миллионером?",
|
quiz: "Кто хочет стать миллионером?",
|
||||||
clicker: "Кликер",
|
clicker: "Кликер",
|
||||||
maze: "Лабиринт",
|
maze: "Лабиринт",
|
||||||
|
snake: "Змейка",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
|
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
|
||||||
quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.",
|
quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.",
|
||||||
clicker: "Бей монстров, качай героев, проходи уровни и боссов — RPG-кликер.",
|
clicker: "Бей монстров, качай героев, проходи уровни и боссов — RPG-кликер.",
|
||||||
maze: "Собери предметы и дойди до выхода по лабиринту.",
|
maze: "Собери предметы и дойди до выхода по лабиринту.",
|
||||||
|
snake: "Классическая змейка — расти, не врезайся в стены и в себя.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const templateSchemas = {
|
export const templateSchemas = {
|
||||||
quiz: quizDefinitionSchema,
|
quiz: quizDefinitionSchema,
|
||||||
clicker: clickerDefinitionSchema,
|
clicker: clickerDefinitionSchema,
|
||||||
maze: mazeDefinitionSchema,
|
maze: mazeDefinitionSchema,
|
||||||
|
snake: snakeDefinitionSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TEMPLATE_DEFAULTS = {
|
export const TEMPLATE_DEFAULTS = {
|
||||||
quiz: DEFAULT_QUIZ_DEFINITION,
|
quiz: DEFAULT_QUIZ_DEFINITION,
|
||||||
clicker: DEFAULT_CLICKER_DEFINITION,
|
clicker: DEFAULT_CLICKER_DEFINITION,
|
||||||
maze: DEFAULT_MAZE_DEFINITION,
|
maze: DEFAULT_MAZE_DEFINITION,
|
||||||
|
snake: DEFAULT_SNAKE_DEFINITION,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseDefinition(templateType: TemplateType, data: unknown) {
|
export function parseDefinition(templateType: TemplateType, data: unknown) {
|
||||||
@@ -41,6 +46,7 @@ export const progressSchemas = {
|
|||||||
quiz: quizProgressSchema,
|
quiz: quizProgressSchema,
|
||||||
clicker: clickerProgressSchema,
|
clicker: clickerProgressSchema,
|
||||||
maze: mazeProgressSchema,
|
maze: mazeProgressSchema,
|
||||||
|
snake: snakeProgressSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function safeParseProgress(templateType: TemplateType, data: unknown) {
|
export function safeParseProgress(templateType: TemplateType, data: unknown) {
|
||||||
@@ -57,10 +63,12 @@ export interface GameResult {
|
|||||||
export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz";
|
export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz";
|
||||||
export type { ClickerDefinition, ClickerHero, ClickerProgress } from "./clicker";
|
export type { ClickerDefinition, ClickerHero, ClickerProgress } from "./clicker";
|
||||||
export type { MazeDefinition, MazeProgress } from "./maze";
|
export type { MazeDefinition, MazeProgress } from "./maze";
|
||||||
|
export type { SnakeDefinition, SnakeProgress } from "./snake";
|
||||||
|
|
||||||
import type { QuizDefinition, QuizProgress } from "./quiz";
|
import type { QuizDefinition, QuizProgress } from "./quiz";
|
||||||
import type { ClickerDefinition, ClickerProgress } from "./clicker";
|
import type { ClickerDefinition, ClickerProgress } from "./clicker";
|
||||||
import type { MazeDefinition, MazeProgress } from "./maze";
|
import type { MazeDefinition, MazeProgress } from "./maze";
|
||||||
|
import type { SnakeDefinition, SnakeProgress } from "./snake";
|
||||||
|
|
||||||
export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition;
|
export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition | SnakeDefinition;
|
||||||
export type GameProgress = QuizProgress | ClickerProgress | MazeProgress;
|
export type GameProgress = QuizProgress | ClickerProgress | MazeProgress | SnakeProgress;
|
||||||
Reference in new issue
Block a user