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:
ogrechkoandClaude Sonnet 5 committed 2026-08-01 03:49:26 +00:00
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>
); );
} }
+2 -1
View File
@@ -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() {
+11
View File
@@ -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>
); );
} }
+5
View File
@@ -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>
); );
} }
+160
View File
@@ -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>
);
}
+2 -1
View File
@@ -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({
+2
View File
@@ -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. */
+1 -1
View File
@@ -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(),
+3
View File
@@ -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;
} }
} }
+231
View File
@@ -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 });
}
}
+47
View File
@@ -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
View File
@@ -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;