Возможно некоторые из вас принимали участие в хакатонах или геймтонах, но кто из вас занимался их разработкой? Сегодня мы разработаем с 0 собственный геймтон и запустим соревнования среди хабравчан и всех желающих just for fun. А также дадим возможность запустить свой геймтон локально по своим правилам Под катом вас ждет разработка геймтона на стеке nodejs + prisma + vuejs + fastify. А также пример разработки фулстек приложения с различными тонкостями построения API.

Общая концепция игры

Существует виртуальный холст (canvas) размером 1024 х 768px на условном удалении от холста в 1000px по центру относительно холста находится катапульта которая может стрелять цветами (условно как пейнтбольное ружье). Есть 5 уровней с разными изображениями. Игрок сам выбирает какие цвета зарядить для выстрела (цвета будут смешаны в момент выстрела) а также выбрать угол наводки оружия на холст по Y и X координатам и силу выстрела. Чем больше цветов в 1 выстреле тем больше итоговое пятно выстрела (работает когда цветов более 3). Игрок имеет ограниченный набор цветов (по умолчанию в конфиге максимум 2000 цветов) и может генерировать и добавлять себе в набор по 5 цветов за 1 запрос. Задача игрока используя управление катапультой изобразить на холсте максимально приближенное изображение к изображению уровня.
Возможно получилось сумбурно, но если коротко - ваша задача с помощью выстрелов нарисовать изображение уровня на холсте. Выиграл тот, у кого наиболее похожее изображение.

Инетерес этого геймтона еще в том, что в нем могут принять участие любые люди, кто умеет вызывать API запросы. Вы свободны в выборе стека и скриптов, с помощью которых придете в топ лидеров.

Технологический стек

Т.к. в основном я занимаюсь frontend разработкой выбор для меня был очевиден это TypeScript + nodejs + fastify + vuejs и еще несколько библиотек, напишу о каждой по порядку

  • fastify - Выступает в роли http сервера, думаю в представлении не нуждается

  • @fastify/cors - будет использоваться для регулирования кроссдоменных запросов

  • @fastify/rate-limit - ограничивает количество запросов от 1 пользователя. Является частью игровой механики для искуственного ограничения скорости запросов к серверу, что помогает не уложить сервер от DDOS а также уравнять пользователей с разным пингом по умолчанию я установил скорость запросов для 1 токена в 120 запросов в минуту.

  • @fastify/static - через static будем раздавать статику нашего vue SPA приложения с таблицей лидеров

  • @fastify/swagger - с помощью аннотаций к нашим API методам будем сразу выстраивать swagger UI документацию для удобства игроков``

  • prisma - ORM в которой мы составим основные схемы сущностей, а также избежим по максимум написания голых запросов.

  • sqlite3 - для асинхронного неблокирующего биндинга с БД для Prisma

  • canvas - Импементация web canvas API для nodejs Ну и tsc с typescript думаю в представлении не нуждаются)

Готовим основные сущности в БД с Prisma Schema

Сущностей\таблиц в проекте всего 3 это сам пользователь, цвета которые генерирует пользователь во время игры и сущность уровня пользователя в которой мы храним статистику по уровню - количество выстрелов, промахов и общий счет баллов.

model User {
  id  Int @id @default(autoincrement())
  nickname    String   @unique
  token String @unique
  level Int
  colors Color[]
  levels Level[]
}

model Color {
  id  Int @id @default(autoincrement())
  user  User @relation(fields: [userId], references: [id])
  userId Int
  color String
}

model Level {
  id  Int @id @default(autoincrement())
  level Int
  user  User @relation(fields: [userId], references: [id])
  userId Int
  score Int
  miss  Int
  shots Int
}

После описания схемы в проекте необходимо выполнить команды npx prisma generate для генерации клиента Prisma и npx prisma db push для применения схемы к текущей базе данных.

Реализация API

Ссылка на Swagger
В проекте всего 8 API давайте рассмотрим каждую из них + я покажу пару моментов при реализации APIшек
Для удоства я все API буду складывать в папку src/server/api и с помощью простого кода

/**
 * Регистрируем API роуты
 */
app.register(async (app) => {
  // Автоматически вычитываем файлы из папки /api
  const apiDirectory = path.join(__dirname, "api"); 
  const apiRoutes = fs
    .readdirSync(apiDirectory)
    .filter((file) => file.endsWith(".js")) 
    .map((file) => file.replace(".js", ""));
  apiRoutes.forEach((route) => {
    console.log("Register route", route);
    require("./api/" + route)(app);
  });
});

Буду регистрировать файлы как API роуты, это избавляет от необходимости каждый раз в ручную регистрировать файлы роутов в fastify приложении

  1. Регистрация - POST /api/user/register - тут ничего сложного, пользователь отправляем нам nickname в запросе и получает в ответ token который будет использоваться для доступа ко всем игровым API

Реализация API /user/register
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import * as crypto from "crypto";
import config from "../../config";
import { response400 } from "./schemas/response400";
const prisma = new PrismaClient();

module.exports = (app: FastifyInstance) => {
  app.post<{ Body: { nickname: string } }>(
    "/api/user/register",
    {
      config: {
        rateLimit: {
          max: config.maxRPM,
          timeWindow: "1 minute",
        },
      },
      schema: {
        body: {
          type: "object",
          required: ["nickname"],
          properties: {
            nickname: { type: "string" },
          },
        },
        tags: ["User"],
        response: {
          200: {
            type: "object",
            properties: {
              nickname: { type: "string" },
              level: { type: "number" },
              token: { type: "string" },
            },
          },
          ...response400
        },
      },
    },
    async (request, reply) => {
      const { nickname } = request.body;
      const token = crypto
        .createHash("sha256")
        .update(nickname + Date.now())
        .digest("hex");
      //Проверяем есть ли  пользователь с таким ником
      const existUser = await prisma.user.findFirst({
        where: {
          nickname,
        },
      });
      if (existUser) {
        reply
          .status(400)
          .send({
            error: "Пользователь с таким никнеймом уже зарегестрирован",
          });
      }
      //Создаем пользователя
      const user = await prisma.user.create({
        data: {
          nickname,
          level: 1,
          token,
        },
      });

      //Создаем запись о первом уровне
      const usreLevel = await prisma.level.create({
        data: {
          userId: user.id,
          level: 1,
          shots: 0,
          miss: 0,
          score: 0,
        },
      });

      reply.status(200).send({ nickname, level: 1, token });
    }
  );
};

  1. Получение текущего холста пользователя - GET /api/user/level - для того чтобы получить свой текущий холст и результаты выстрелов, пользователь может запросить свой уровень передав токен в заголовках полученный при регистрации. Для всех API где мы получаем пользователя по токену, я вынес функцию валидации и проверки токена в отдельный файл src/server/api/middleware/preValidation.ts c его помощью, при успешной авторизации, нам всегда будет доступен пользователь в текущем запросе req.user - это очень удобно при работе с API требующими данные пользователя

Реализация middleware/preValidation.ts
import { PrismaClient } from '@prisma/client';
import { FastifyReply, HookHandlerDoneFunction } from 'fastify';
import { FastifyRequest } from 'fastify/types/request';

const prisma = new PrismaClient();

export const preValidation = async function(request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) {
    const token = request.headers["token"] as string;
    // Check if the token exists in the headers
    if (!token) {
      reply.code(401).send({ error: "Token is missing in headers" });
      done();
      return;
    }
    // Check if the token exists in the Prisma user table
    const user = await prisma.user.findUnique({
      where: {
        token: token,
      },
    });
    if (!user) {
      reply.code(401).send({ error: "Invalid token" });
      done();
      return;
    }
    request.user = user;
  };

Главное не забыть еще расширить тип FastifyRequest новыми данными

declare module 'fastify' {
  interface FastifyRequest {
    user?: {
      id: number;
      nickname: string;
      token: string;
      level: number;
    };
    rateLimit: {
      current: number;
      remaining: number;
    };
  }
}

Ну и сама реализация получения холста пользователя, ничего сложного, просто получаем холст (png изображение) на основе текущего уровня и ID пользователя

Реализация /user/level
import { FastifyInstance, FastifyRequest } from "fastify";
import path from "path";
import fs from "fs";
import config from "../../config";
module.exports = (app: FastifyInstance) => {
  app.get(
    "/api/user/level",
    {
      config: {
        rateLimit: {
          max: config.maxRPM,
          timeWindow: '1 minute'
        }
      },
      schema: {
        summary: "Получить текущий уровень пользователя",
        description:
          "Возвращает PNG-изображение уровня пользователя",
        tags: ["User"],
        querystring: {
          type: "object",
          required: ["userId", "level"],
          properties: {
            userId: {
              type: "string",
              description: "ID пользователя",
            },
            level: {
              type: "string",
              description: "Номер уровня",
            },
          },
        },
        response: {
          200: {
            description: "PNG-изображение",
            headers: {
              "Content-Type": { type: "string", enum: ["image/png"] },
            },
            type: "string",
            format: "binary",
          },
          404: {
            description: "Изображение не найдено",
            type: "object",
            properties: {
              error: { type: "string" },
              message: { type: "string" },
            },
          },
          400: {
            description: "Ошибка валидации параметров",
            type: "object",
            properties: {
              error: { type: "string" },
              message: { type: "string" },
            },
          },
        },
      },
    },
    /**
     * Возвращаем изображение текущего уровня пользователю
     */
    async (
      request: FastifyRequest<{
        Querystring: { userId: string; level: string };
      }>,
      reply
    ) => {
      const { userId, level } = request.query;

      // Валидация параметров
      if (!userId || !level) {
        return reply.status(400).send({
          error: "Validation Error",
          message: "Параметры userId и level обязательны",
        });
      }

      const imageName = `${userId}-${level}.png`;
      const imagePath = path.join(__dirname, "../api/images", imageName);

      if (fs.existsSync(imagePath)) {
        return reply
          .header("Content-Type", "image/png")
          .send(fs.readFileSync(imagePath));
      } else {
        return reply.status(404).send({
          error: "Image not found",
          message: `Изображение ${imageName} не найдено`,
        });
      }
    }
  );
};

  1. После регистрации, знакомства со своим холстом стоит познакомиться с изображением уровня которое нам необходимо будет изобразить на холсте. Для этого служит API GET /api/level/source в целом API не отличается от предыдущей за исключением того, что возвращает PNG изображение текущего уровня пользователя, а не его холста.

Реализация /api/level.source
import { FastifyInstance, FastifyRequest } from "fastify";
import path from "path";
import fs from "fs";
import config from "../../config";
import { preValidation } from "./middleware/preValidation";
import { response400 } from "./schemas/response400";

module.exports = (app: FastifyInstance) => {
  app.get(
    "/api/level/source",
    {
      config: {
        rateLimit: {
          max: config.maxRPM,
          timeWindow: '1 minute'
        }
      },
      preValidation,
      schema: {
        security: [{ apiToken: [] }],
        summary: "Получить изображение текущего уровня",
        description:
          "Возвращает PNG-изображение текущего уровня пользователя",
        tags: ["Level"],
        response: {
          200: {
            description: "PNG-изображение",
            headers: {
              "Content-Type": { type: "string", enum: ["image/png"] },
            },
            type: "string",
            format: "binary",
          },
          404: {
            description: "Изображение не найдено",
            type: "object",
            properties: {
              error: { type: "string" },
              message: { type: "string" },
            },
          },
          ...response400
        },
      },
    },
    /**
     * Возвращаем изображение текущего уровня пользователю
     */
    async (
      request,
      reply
    ) => {
      const imageName = `${request.user?.level}.png`;
      const imagePath = path.join(__dirname, "../../../levels", imageName);

      if (fs.existsSync(imagePath)) {
        return reply
          .header("Content-Type", "image/png")
          .send(fs.readFileSync(imagePath));
      } else {
        return reply.status(404).send({
          error: "404",
          message: `Изображение ${imagePath} не найдено`,
        });
      }
    }
  );
};

  1. Теперь, когда мы научились работать с текущим уровнем и холстом, настало время подготовиться к первому выстрелу, но прежде чем мы совершим первый выстрел, нам необходимо сгенерировать себе первый набор цветов. Для этого служит API GET /api/colors/generate - в результате вызова API вы получите в свое распоряжение 5 hex цветов которые уже можно использовать для выстрела по холсту. Функция генерации рандомных цветов и их запись в БД достаточно проста, по этому не будем останавливаться на ней.

Реализация colors/generate
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { preValidation } from "./middleware/preValidation";
import config from "../../config";

const prisma = new PrismaClient();

// Function to generate a random color in RGB HEX format
function generateRandomColor() {
  return (
    "#" +
    Math.floor(Math.random() * 16777215)
      .toString(16)
      .padStart(6, "0")
  );
}

module.exports = (app: FastifyInstance) => {
  app.get(
    "/api/colors/generate",
    {
      config: {
        rateLimit: {
          max: config.maxRPM,
          timeWindow: "1 minute",
        },
      },
      schema: {
        security: [{ apiToken: [] }],
        summary: "Генерация цветов",
        description: "Генерирует 5 случайных цветов за 1 ход",
        tags: ["Colors"],
        response: {
          200: {
            type: "object",
            properties: {
              colors: { type: "array" },
            },
          },
        },
      },
      preValidation,
    },
    async (request, reply) => {
      let user = request.user;
      if (user) {
        const userColorsCount = await prisma.color.count({
          where: { userId: +user.id },
        });
        let genColorsCount = 5;

        if (userColorsCount === config.colorsLimit) {
          reply.code(400).send({ error: "Limit colors reached" });
        }
        if (userColorsCount + 5 > config.colorsLimit) {
          genColorsCount = config.colorsLimit - userColorsCount;
        }

        // Generate random colors
        const colors = Array.from({ length: genColorsCount }, () =>
          generateRandomColor()
        );
        // Add the generated colors to the database using Prisma
        await Promise.all(
          colors.map(async (color) => {
            return prisma.color.create({
              data: {
                color,
                userId: +user.id,
              },
            });
          })
        );
        reply.code(200).send({ colors });
      } else {
        reply.code(401).send({ error: "Invalid user" });
      }
    }
  );
};

  1. После того, как мы нагенерировали себе множество цветов, мы можем получить их все запросом GET /api/colors/list из которых мы уже можем выбирать какие цвета будем использовать для выстрела. При выстреле, цвета будут смешиваться в один цвет. Как математически выглядит смешивание цветов? На самом деле достаточно просто, по сути смешанный цвет это среднее арифметическое от цветов и высчитывается достаточно просто

/**
 * Смешивание цветов
 * @param colors массив hex цветов
 * @returns hex смешанный цвет
 */
const mixColors = (colors: string[]): string => {
  let r = 0,
    g = 0,
    b = 0;

  colors.forEach((hex) => {
    const bigint = parseInt(hex.slice(1), 16);
    r += (bigint >> 16) & 255;
    g += (bigint >> 8) & 255;
    b += bigint & 255;
  });

  const colorCount = colors.length;
  r = Math.floor(r / colorCount);
  g = Math.floor(g / colorCount);
  b = Math.floor(b / colorCount);

  return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};

Сумму цветов по каждому каналу делим на количество цветов и получаем итоговый цвет. Easy)

  1. Наконец, когда мы уже можем приступить к самой интересной части геймтона - стрельбе по холсту POST /api/game/shoot. Я реализовал механику таким образом, что снаряд в нашем киберпространстве не имеет сопротивления и при любом значении силы выстрела power ваш снаряд долетит до холста рано или поздно т.к. в формуле баллистики не учитывается сопротивление таким образом вам всегда гарантирован долет до линии холста и промах или попадание. Если вы попали - в ответ API вернет вам PNG изображения обновленного холста, а если вы промажете - API вернет вам координаты промаха, чтобы вам было проще пристреляться. Готовой формулы расчета и перевода целевых координат пикселя в выстрел я приводить не буду ради сохранения спортивного интереса, хотя весь код расчета выстрела доступен на гитхабе и любая ИИшка расскажет вам секрет расчета правильного выстрела) Остановлюсь на механике выстрела более подробно. Первым делом мы проверяем, если у пользователя цвета, которые он передал для выстрела. Т.к. один и тотже цвет в выстреле может присутствовать более 1 раза, необходимо проверить не только наличие цвета в таблице, но и количество записей с таким цветов у пользователя

// Получаем актуальные количества цветов
        const colorEntries = await prisma.color.groupBy({
          by: ["color"],
          where: { userId: user.id },
          _count: { color: true },
        });

Для подсчета количества в Prisma добавляем параметр _count: { color: true }, при запросе.
Далее удаляем каждый цвет из БД но т.к. цвета могут повторяться, необходимо удалить только нужное количество цветов из таблицы пользователя, в этом моменте инструментов Prisma не хватает, по этому пришлось написать raw SQL запрос удаляющий определенный цвет в определенном количестве

// Проверяем доступность и готовим удаление
        const colorsToDelete = [];
        const unavailableColors = [];

        for (const [color, requestedCount] of Object.entries(
          requestColorCounts
        )) {
          const availableCount = dbColorCounts[color] || 0;

          if (availableCount >= requestedCount) {
            colorsToDelete.push({ color, count: requestedCount });
          } else {
            unavailableColors.push(color);
          }
        }
        //Если есть недоступные цвета, отменяем удаление
        if (unavailableColors.length > 0) {
          return { availableColors: [], unavailableColors };
        } else {
          // Удаляем доступные цвета
          for (const { color, count } of colorsToDelete) {
            await prisma.$executeRaw`
              DELETE FROM Color
              WHERE id IN (
                SELECT id FROM Color
                WHERE userid = ${user.id} 
                  AND color = ${color}
                ORDER BY id
                LIMIT ${count}
              );
            `;
          }

Убедившись, что цветов для выстрела достаточно. Считаем примитивную балистику

 // Считаем баллистическую траекторию
      const canvasWidth = config.canvasWidth;
      const canvasHeight = config.canvasHeight;
      //Кеф гравитации
      const g = 1; 
      //Дистанция до полотна
      const L = 200; 
      const V0 = power / colors.length;
      const cosY = Math.cos(radianAngleY);
      const sinY = Math.sin(radianAngleY);
      const cosX = Math.cos(radianAngleX);
      const sinX = Math.sin(radianAngleX);
      const Vz = V0 * cosY * cosX;
      const Vx = V0 * cosY * sinX;
      const Vy = -V0 * sinY;
      const t = L / Vz;
      const targetY = canvasHeight + Vy * t + (g * t ** 2) / 2;
      const targetX = canvasWidth / 2 + Vx * t;

И проверяем итоговые targetY и targetX что попадают в нашу область канваса размером 1024 на 768px

// Проверяем, что попал в область канваса
      if (
        targetX < 0 ||
        targetX > canvasWidth ||
        targetY < 0 ||
        targetY > canvasHeight
      ) {
        //Если попал, обновляем данные о уровне
        updateLevel(user.id, user.level, true);
        return reply.status(400).send({ error: "Промах!", targetX, targetY });
      }
      const canvas = createCanvas(canvasWidth, canvasHeight);
      const ctx = canvas.getContext("2d");
      // Размер круга попадания зависит от количества красок
      const circleSize = colors.length - 3 > 0 ? colors.length - 3 : 1;
      const image = await loadImage(imagePath);
      ctx.drawImage(image, 0, 0);

      // Рисуем круг в месте попадания
      ctx.fillStyle = mixedColor;
      ctx.beginPath();
      ctx.arc(targetX, targetY, circleSize, 0, Math.PI * 2);
      ctx.fill();
      // Сохраняем канвас как изображение
      const buffer = canvas.toBuffer("image/png");
      fs.writeFileSync(imagePath, new Uint8Array(buffer));
      reply.header("Content-Type", "image/png").send(buffer);

      updateLevel(user.id, user.level);

Теперь после выстрела по холсту, успешному попаданию и занесении в статистику информации о выстреле, нам нужно посчитать количество очков, полученных за выстрел. Для этой калькуляции я решил применить мультитред вычисления на основе nodejs Worker, чтобы сервер на захлебулся от вычислений, запуск воркера я завернул в debounce функцию с таймаутом в 3 секунды. Таким образом, мы пересчитываем количество очков у пользователя только спустя 3 секунды после выстрела пользователя (можно поменять через конфиг)

Теперь несколько слов про расчет баллов за выстрел. Нам необходимо сравнить холст пользователя с изображением уровня. Сравнивать будем каждый пиксель и разницу между ними. Например мы выстрелили черным пикселем в то место, где у нас белый фон (такие кейсы исключены, белые пиксели и прозрачные при расчете пропускаются, но для примера сойдет) Мы имеем цвет выстрела RGB (0,0,0) и белый цвет на холсте RGB(255,255,255)
Вычитаем из каждого цвета свой цвет и таким образом получаем максимальную разницу в 765 (255+255+255) соответственно, если бы мы выстрелили серым цветом в область которая должна быть черная например RGB(100,100,100) вычитаем из RGB(0,0,0) и получаем суммарную разницу в 300 т.е чем меньше результат вычисления, тем ближе пиксель по цвету к другому пикселу. Итоговую разницу вычитаем из максимальной разницы 765 - в нашем примере с серым цветом это 765-300 = 465 т.е. мы за выстрел серым по черному мы получаем 465 очков, а если бы был почти черный по черному например (250, 250,250) то дельта цветов составила бы 765-15=750 баллов за попадание в цвет. Итоговый результат я делю на 1000 чтобы немного уменьшить итоговые числа и убрать несколько разрядов.

Реализация расчета баллов за выстрел
import path from "path";
import { createCanvas, loadImage } from "canvas";
import { parentPort } from "worker_threads";
import { PrismaClient } from "@prisma/client";
import config from "../config";
const prisma = new PrismaClient();

if (parentPort) {
  parentPort.on("message", async (shot: { level: number; userId: number }) => {
    console.log("message", shot);
    try {
      parentPort?.postMessage({
        success: true,
        score: await clacRate(shot.level, shot.userId),
      });
    } catch (error: any) {
      parentPort?.postMessage({
        success: false,
        error: error.message,
      });
    }
  });
}

async function clacRate(level: number, userId: number) {
  console.time("score");
  const width = config.canvasWidth,
    height = config.canvasHeight;
  let score = 0;
  const levelImage = `${userId}-${level}.png`;
  console.log("start calculate", level, userId, levelImage);
  //Prepare source image
  const sourceImage = await loadImage(
    path.join(__dirname, "../../levels", `${level}.png`)
  );
  const sourceCanvas = createCanvas(width, height);
  const sourceCtx = sourceCanvas.getContext("2d");
  sourceCtx.drawImage(sourceImage, 0, 0);
  const sourceData = sourceCtx.getImageData(0, 0, width, height);

  //Prepare level image
  const levelImg = await loadImage(
    path.join(__dirname, "/api/images", levelImage)
  );
  const levelCanvas = createCanvas(width, height);
  const levelCtx = levelCanvas.getContext("2d");
  levelCtx.drawImage(levelImg, 0, 0);
  const levelData = levelCtx.getImageData(0, 0, width, height);

  // Проверяем каждый пиксель построчно
  for (let i = 0; i < sourceData.data.length; i += 4) {
    const r = sourceData.data[i]; // Red
    const g = sourceData.data[i + 1]; // Green
    const b = sourceData.data[i + 2]; // Blue
    const a = sourceData.data[i + 3]; // Alpha
    //Пропускаем прозрачные пиксели
    if (a === 0) {
      continue;
    }
    const lr = levelData.data[i]; // Red
    const lg = levelData.data[i + 1]; // Green
    const lb = levelData.data[i + 2]; // Blue
    const la = levelData.data[i + 3]; // Alpha

    //Skip white and transparent pixels
    if (
      a > 0 &&
      r < 255 &&
      g < 255 &&
      b < 255 &&
      la > 0 &&
      lr < 255 &&
      lg < 255 &&
      lb < 255
    ) {
      //Debug info
      score += 765 - (r - lr + (g - lg) + (b - lb));
    }
  }
  console.log("score", Math.round(score));
  score = Number(score / 1000);
  console.timeEnd("score");
  //Обновляем информацию в БД
  const currentLevel = await prisma.level.findFirst({
    where: {
      userId,
      level,
    },
  });

  if (currentLevel) {
    await prisma.level.update({
      where: {
        id: currentLevel.id,
      },
      data: {
        score: score
      }
    });
  }
  return score;
}

Расчет одного холста на хостинге с CPU 3.1ghz занимает около 200ms

  1. После того, как вы настрелялись по уровню и готовы приступить к следующему, вам потребуется вызвать API GET /api/level/next - после вызова данного API вы будете переключены на следующий уровень (я подготовил их 5 от простого к сложному) после переключения уровня, обратной дороги на предыдущий уровень уже не будет.

Реализация level/next
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { preValidation } from "./middleware/preValidation";
import config from "../../config";
const prisma = new PrismaClient();

module.exports = (app: FastifyInstance) => {
  app.get<{ Body: { nickname: string } }>(
    "/api/level/next",
    {
      config: {
        rateLimit: {
          max: config.maxRPM,
          timeWindow: "1 minute",
        },
      },
      schema: {
        security: [{ apiToken: [] }],
        tags: ["Level"],
        summary: "Переключиться на следующий уровень",
        description: "После вызова, пользователю становится доступен следующий уровень, действие нельзя отменить",
        response: {
          200: {
            type: "object",
            properties: {
              level: { type: "number" },
            },
          },
        },
      },
      preValidation,
    },
    async (request, reply) => {
      if (request.user) {
        const currentLevel = request.user?.level;
        let newLevel = currentLevel;
        console.log("currentLevelt", currentLevel, "maxlbl", config.levels);
        if (config.levels > currentLevel) {
          newLevel = currentLevel + 1;
        }
        //Переводим пользователя на новый уровень
        await prisma.user.update({
          where: { id: request.user.id },
          data: {
            level: newLevel,
          },
        });
        //Создаем запись про новый уровень
        const userLevel = await prisma.level.create({
          data: {
            userId: request.user.id,
            level: newLevel,
            shots: 0,
            miss: 0,
            score: 0,
          },
        });
        reply.status(200).send({ level: newLevel });
      } else {
        reply.status(401).send({ error: "Invalid user" });
      }
    }
  );
};

  1. Ну и финальная API это получение результатов игры. Используется для построения таблицы лидеров.

Реализация api/game/results
import { FastifyInstance } from 'fastify';
import { PrismaClient } from '@prisma/client';
import config from '../../config';

const prisma = new PrismaClient();

module.exports = function (app: FastifyInstance) {
  app.get('/api/game/results', {
    config: {
        rateLimit: {
          max: config.maxRPM,
          timeWindow: "1 minute",
        },
      },
    schema: {
      summary: "Получение результатов игроков",
      description: "Возвращает результаты игроков, сгруппированные по userId и отсортированные по общему score",
      tags: ["Game"],
      querystring: {
        type: 'object',
        properties: {
          order: { 
            type: 'string',
            enum: ['asc', 'desc'],
            default: 'desc'
          }
        }
      },
      response: {
        200: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              userId: { type: 'number' },
              totalScore: { type: 'number' },
              nickname: { type: 'string' },
              levels: {
                type: 'array',
                items: {
                  type: 'object',
                  properties: {
                    id: { type: 'number' },
                    level: { type: 'number' },
                    score: { type: 'number' },
                    miss: { type: 'number' },
                    shots: { type: 'number' }
                  }
                }
              }
            }
          }
        }
      }
    }
  }, async (request, reply) => {
    const { order = 'desc' } = request.query as { order?: 'asc' | 'desc' };

    const usersWithLevels = await prisma.user.findMany({
      select: {
        id: true,
        nickname: true,
        levels: {
          select: {
            id: true,
            level: true,
            score: true,
            miss: true,
            shots: true
          }
        }
      }
    });

    const results = usersWithLevels
      .map((user) => {
        const totalScore = user.levels.reduce((sum, level) => sum + level.score, 0);
        return {
          userId: user.id,
          nickname: user.nickname,
          totalScore,
          levels: user.levels
        };
      })
      .sort((a, b) => order === 'desc' ? b.totalScore - a.totalScore : a.totalScore - b.totalScore);

    return reply.send(results);
  });
}

Frontend реализация

Реализации фронта я уделил в целом очень мало внимания, просто сверстал таблицу выбором по какому уровню строить срез, чисто для пробы решил использовать минималистичный css фреймворк pico.css его основная фича в том, что вам не нужно писать классы, достаточно просто писать семантичную разметку. Конечно совсем без классов не обойтись, как минимум class=container я применил, и добавил пару правил в CSS для добавления emoji медалек в таблице лидеров. В остальном же классы не использовались. Общее впечатление - на троечку. Какой-нибудь bootstrap или bulma все-таки мне больше по душе, но как интересный опыт пробы чего-то нового зайдет

Краткая инструкция по установке локально

  1. Клонируем репозиторий

  2. Конфигурируем под себя src/config.ts

  3. Генерируем фронтенд vuejs cd frontend && npm run build

  4. Закидываем в папку level PNG изображения своих уровней (важно чтобы они были одинакового размера) сам размер также можно изменить через config.ts

  5. Генерируете клиент Prisma и пушите в бд npx prisma generate && npx prisma db push

  6. В папке dist/server/api создаете пустую папку images - там будут складывать холсты пользователей

  7. Запускаете сервер node dist/server/index.js

Заключение

Если что, самая идея геймтона не моя подобный хакатон проводился в 2023 году командой DatsTeam с реализацией на Yii php вот пост про мой опыт участия геймтон мне очень понравился и я поставил себе задачу повторить такой ивент в будущем) Допускаю что и DatsTeam где-то подсмотрели эту идею и еще ссылочки

Донатов не собираю, телеграмы не рекламирую, если нравится пост - просто поддержите лайком) Буду признателен.
p.s. Сервер на котором запущен геймтон на бесплатном поддомене от хостера, я оплатил вдску на 3CPU 1gb RAM на 3 месяца - развлекайтесь) топ 10 участников спустя неделю могут расчитывать на плюсик от меня в карму на хабре.

p.p.s. Тестов как таковых не проводил, могут быть шереховатости, постараюсь максимально оперативно исправляться в комментариях) спустя пару дней после поста поделюсь историей нагрузки на сервер в комментариях.

Комментарии (8)


  1. n0isy
    02.09.2025 19:02

    Похоже, оно уже лежит...

    Придется карму статьями фармить. Эх...

    UPD: заработало без TLS-сертификата

    UPD2: на гитхабе неверный default branch


    1. strokoff Автор
      02.09.2025 19:02

      Катил хотфиксы был пару минутный даунтайм) все поднял


      1. n0isy
        02.09.2025 19:02

        Не уверен, что представленная формула максимизирует "совпадение" с уровнем. Такое ощущение что можно стрелять почти-белым. Я не прав?


        1. strokoff Автор
          02.09.2025 19:02

          Т.к цвета получаются рандомные, конечно можно выстрелить почти белым цветом. Но чисто белым не получится он приравнивается к прозрачному т.к вероятность сгенерировать чисто белый цвет крайне мала да и 1 цвет закрасит 1 пиксель. По этому в коде белые и прозрачные пиксели пропускаю при расчете. А вот черный или близко к черному сгенерировать и смешать достаточно легко.


          1. n0isy
            02.09.2025 19:02

            Как-то сбросить юзера / стату можно?


            1. strokoff Автор
              02.09.2025 19:02

              Нет, конечно технически это возможно, но специального инструментария нет. Через прямое удаление записи из бд могу удалить. у вас уже появилась такая необходимость?) как вариант завести второй акк себе


              1. n0isy
                02.09.2025 19:02

                Видимо особо нет, так как точность не учитывается в скоринге.

                Есть баг при переходе на след. уровень: уровни лимитируются, но запись в субд кажется создаётся. Получается много 5тых уровней?


              1. n0isy
                02.09.2025 19:02

                В статистике баг вывода чисел с плавающей запятой https://0.30000000000000004.com/