diff --git a/src/app/create/[template]/manual/page.tsx b/src/app/create/[template]/manual/page.tsx index 9c64a38..54cad69 100644 --- a/src/app/create/[template]/manual/page.tsx +++ b/src/app/create/[template]/manual/page.tsx @@ -4,6 +4,7 @@ import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType } from "@/lib/games/ import { QuizBuilder } from "@/components/builders/quiz-builder"; import { ClickerBuilder } from "@/components/builders/clicker-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 }> }) { const { template } = await params; @@ -23,6 +24,7 @@ export default async function ManualCreatePage({ params }: { params: Promise<{ t {templateType === "quiz" && } {templateType === "clicker" && } {templateType === "maze" && } + {templateType === "snake" && } ); } diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 3c7df24..31d6d04 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; 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 { TEMPLATE_TYPES, TEMPLATE_LABELS, TEMPLATE_DESCRIPTIONS, type TemplateType } from "@/lib/games/types"; @@ -8,6 +8,7 @@ const TEMPLATE_ICONS: Record = { quiz: HelpCircle, clicker: MousePointerClick, maze: MapIcon, + snake: Worm, }; export default async function CreatePage() { diff --git a/src/app/my-games/[id]/edit/page.tsx b/src/app/my-games/[id]/edit/page.tsx index 60fde67..4be25ed 100644 --- a/src/app/my-games/[id]/edit/page.tsx +++ b/src/app/my-games/[id]/edit/page.tsx @@ -7,9 +7,11 @@ 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 { SnakeBuilder } from "@/components/builders/snake-builder"; import type { QuizDefinition } from "@/lib/games/quiz"; import type { ClickerDefinition } from "@/lib/games/clicker"; import type { MazeDefinition } from "@/lib/games/maze"; +import type { SnakeDefinition } from "@/lib/games/snake"; export default async function EditGamePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -59,6 +61,15 @@ export default async function EditGamePage({ params }: { params: Promise<{ id: s initialDefinition={game.definition as MazeDefinition} /> )} + {game.templateType === "snake" && ( + + )} ); } diff --git a/src/components/ai-generate-form.tsx b/src/components/ai-generate-form.tsx index ea2c059..8ec5ecc 100644 --- a/src/components/ai-generate-form.tsx +++ b/src/components/ai-generate-form.tsx @@ -6,9 +6,11 @@ import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType, type GameDefinition import type { QuizDefinition } from "@/lib/games/quiz"; import type { ClickerDefinition } from "@/lib/games/clicker"; import type { MazeDefinition } from "@/lib/games/maze"; +import type { SnakeDefinition } from "@/lib/games/snake"; import { QuizBuilder } from "@/components/builders/quiz-builder"; import { ClickerBuilder } from "@/components/builders/clicker-builder"; import { MazeBuilder } from "@/components/builders/maze-builder"; +import { SnakeBuilder } from "@/components/builders/snake-builder"; export function AiGenerateForm() { const [templateType, setTemplateType] = useState("quiz"); @@ -56,6 +58,9 @@ export function AiGenerateForm() { {templateType === "maze" && ( )} + {templateType === "snake" && ( + + )} ); } diff --git a/src/components/builders/snake-builder.tsx b/src/components/builders/snake-builder.tsx new file mode 100644 index 0000000..8e46212 --- /dev/null +++ b/src/components/builders/snake-builder.tsx @@ -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(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 ( +
+
+ + + +
+ + + + +
+ + +

+ Скорость растёт по мере роста змейки автоматически. Длина для победы не может превышать ширину × высоту. +

+
+ + {error &&

{error}

} + + +
+ ); +} diff --git a/src/components/game-card.tsx b/src/components/game-card.tsx index 9ff5958..722fdaf 100644 --- a/src/components/game-card.tsx +++ b/src/components/game-card.tsx @@ -1,11 +1,12 @@ 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"; const TEMPLATE_ICONS: Record = { quiz: HelpCircle, clicker: MousePointerClick, maze: MapIcon, + snake: Worm, }; export function GameCard({ diff --git a/src/lib/ai/generate.ts b/src/lib/ai/generate.ts index 1507d79..515d131 100644 --- a/src/lib/ai/generate.ts +++ b/src/lib/ai/generate.ts @@ -8,6 +8,8 @@ const SYSTEM_PROMPTS: Record = { clicker: "Ты создаёшь 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, поэтому каждая точка, усиливающая точка и выход обязательно должны быть достижимы от старта по коридорам без стен.", + 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. */ diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 89b3122..7305d99 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -43,7 +43,7 @@ export const games = sqliteTable("games", { .references(() => users.id, { onDelete: "cascade" }), title: text("title").notNull(), 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"), // JSON string validated against the templateType's zod schema (src/lib/games/*). definition: text("definition").notNull(), diff --git a/src/lib/games/scenes/index.ts b/src/lib/games/scenes/index.ts index 61f1623..8831c8e 100644 --- a/src/lib/games/scenes/index.ts +++ b/src/lib/games/scenes/index.ts @@ -2,6 +2,7 @@ import type Phaser from "phaser"; import { QuizScene } from "./quiz-scene"; import { ClickerScene } from "./clicker-scene"; import { MazeScene } from "./maze-scene"; +import { SnakeScene } from "./snake-scene"; import type { TemplateType } from "../types"; export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene { @@ -12,5 +13,7 @@ export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene { return ClickerScene; case "maze": return MazeScene; + case "snake": + return SnakeScene; } } diff --git a/src/lib/games/scenes/snake-scene.ts b/src/lib/games/scenes/snake-scene.ts new file mode 100644 index 0000000..6280902 --- /dev/null +++ b/src/lib/games/scenes/snake-scene.ts @@ -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 = { + up: { dr: -1, dc: 0 }, + down: { dr: 1, dc: 0 }, + left: { dr: 0, dc: -1 }, + right: { dr: 0, dc: 1 }, +}; +const OPPOSITE: Record = { 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 }); + } +} diff --git a/src/lib/games/snake.ts b/src/lib/games/snake.ts new file mode 100644 index 0000000..8b51a60 --- /dev/null +++ b/src/lib/games/snake.ts @@ -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; + +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; diff --git a/src/lib/games/types.ts b/src/lib/games/types.ts index 78be01d..799d237 100644 --- a/src/lib/games/types.ts +++ b/src/lib/games/types.ts @@ -1,32 +1,37 @@ import { quizDefinitionSchema, quizProgressSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz"; import { clickerDefinitionSchema, clickerProgressSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker"; 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 const TEMPLATE_LABELS: Record = { quiz: "Кто хочет стать миллионером?", clicker: "Кликер", maze: "Лабиринт", + snake: "Змейка", }; export const TEMPLATE_DESCRIPTIONS: Record = { quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.", clicker: "Бей монстров, качай героев, проходи уровни и боссов — RPG-кликер.", maze: "Собери предметы и дойди до выхода по лабиринту.", + snake: "Классическая змейка — расти, не врезайся в стены и в себя.", }; export const templateSchemas = { quiz: quizDefinitionSchema, clicker: clickerDefinitionSchema, maze: mazeDefinitionSchema, + snake: snakeDefinitionSchema, }; export const TEMPLATE_DEFAULTS = { quiz: DEFAULT_QUIZ_DEFINITION, clicker: DEFAULT_CLICKER_DEFINITION, maze: DEFAULT_MAZE_DEFINITION, + snake: DEFAULT_SNAKE_DEFINITION, }; export function parseDefinition(templateType: TemplateType, data: unknown) { @@ -41,6 +46,7 @@ export const progressSchemas = { quiz: quizProgressSchema, clicker: clickerProgressSchema, maze: mazeProgressSchema, + snake: snakeProgressSchema, }; export function safeParseProgress(templateType: TemplateType, data: unknown) { @@ -57,10 +63,12 @@ export interface GameResult { export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz"; export type { ClickerDefinition, ClickerHero, ClickerProgress } from "./clicker"; export type { MazeDefinition, MazeProgress } from "./maze"; +export type { SnakeDefinition, SnakeProgress } from "./snake"; import type { QuizDefinition, QuizProgress } from "./quiz"; import type { ClickerDefinition, ClickerProgress } from "./clicker"; import type { MazeDefinition, MazeProgress } from "./maze"; +import type { SnakeDefinition, SnakeProgress } from "./snake"; -export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition; -export type GameProgress = QuizProgress | ClickerProgress | MazeProgress; +export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition | SnakeDefinition; +export type GameProgress = QuizProgress | ClickerProgress | MazeProgress | SnakeProgress;