Возможно некоторые из вас принимали участие в хакатонах или геймтонах, но кто из вас занимался их разработкой? Сегодня мы разработаем с 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- для асинхронного неблокирующего биндинга с БД для Prismacanvas- Импементация 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 приложении
Регистрация - 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 }); } ); };
Получение текущего холста пользователя - GET
/api/user/level- для того чтобы получить свой текущий холст и результаты выстрелов, пользователь может запросить свой уровень передав токен в заголовках полученный при регистрации. Для всех API где мы получаем пользователя по токену, я вынес функцию валидации и проверки токена в отдельный файлsrc/server/api/middleware/preValidation.tsc его помощью, при успешной авторизации, нам всегда будет доступен пользователь в текущем запросе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} не найдено`, }); } } ); };
После регистрации, знакомства со своим холстом стоит познакомиться с изображением уровня которое нам необходимо будет изобразить на холсте. Для этого служит 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} не найдено`, }); } } ); };
Теперь, когда мы научились работать с текущим уровнем и холстом, настало время подготовиться к первому выстрелу, но прежде чем мы совершим первый выстрел, нам необходимо сгенерировать себе первый набор цветов. Для этого служит 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" }); } } ); };
После того, как мы нагенерировали себе множество цветов, мы можем получить их все запросом 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)
Наконец, когда мы уже можем приступить к самой интересной части геймтона - стрельбе по холсту POST
/api/game/shoot. Я реализовал механику таким образом, что снаряд в нашем киберпространстве не имеет сопротивления и при любом значении силы выстрела power ваш снаряд долетит до холста рано или поздно т.к. в формуле баллистики не учитывается сопротивление таким образом вам всегда гарантирован долет до линии холста и промах или попадание. Если вы попали - в ответ API вернет вам PNG изображения обновленного холста, а если вы промажете - API вернет вам координаты промаха, чтобы вам было проще пристреляться. Готовой формулы расчета и перевода целевых координат пикселя в выстрел я приводить не буду ради сохранения спортивного интереса, хотя весь код расчета выстрела доступен на гитхабе и любая ИИшка расскажет вам секрет расчета правильного выстрела) Остановлюсь на механике выстрела более подробно.
Cхема Первым делом мы проверяем, если у пользователя цвета, которые он передал для выстрела. Т.к. один и тотже цвет в выстреле может присутствовать более 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 ) { // Вычисляем разницу по каждому каналу и суммируем const diffR = Math.abs(r - lr); const diffG = Math.abs(g - lg); const diffB = Math.abs(b - lb); // Score = максимальная разница минус фактическая разница score += 765 - (diffR + diffG + diffB); } } 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
После того, как вы настрелялись по уровню и готовы приступить к следующему, вам потребуется вызвать 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" }); } } ); };
Ну и финальная 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 все-таки мне больше по душе, но как интересный опыт пробы чего-то нового зайдет
Краткая инструкция по установке локально
Клонируем репозиторий
Конфигурируем под себя src/config.ts
Генерируем фронтенд vuejs
cd frontend && npm run buildЗакидываем в папку level PNG изображения своих уровней (важно чтобы они были одинакового размера) сам размер также можно изменить через config.ts
Генерируете клиент Prisma и пушите в бд
npx prisma generate && npx prisma db pushВ папке
dist/server/apiсоздаете пустую папку images - там будут складывать холсты пользователейЗапускаете сервер
node dist/server/index.js
Заключение
Если что, самая идея геймтона не моя подобный хакатон проводился в 2023 году командой DatsTeam с реализацией на Yii php вот пост про мой опыт участия геймтон мне очень понравился и я поставил себе задачу повторить такой ивент в будущем) Допускаю что и DatsTeam где-то подсмотрели эту идею и еще ссылочки
Донатов не собираю, телеграмы не рекламирую, если нравится пост - просто поддержите лайком) Буду признателен.
p.s. Сервер на котором запущен геймтон на бесплатном поддомене от хостера, я оплатил вдску на 3CPU 1gb RAM на 3 месяца - развлекайтесь) топ 10 участников спустя неделю могут расчитывать на плюсик от меня в карму на хабре.
p.p.s. Тестов как таковых не проводил, могут быть шереховатости, постараюсь максимально оперативно исправляться в комментариях) спустя пару дней после поста поделюсь историей нагрузки на сервер в комментариях.