Возможно некоторые из вас принимали участие в хакатонах или геймтонах, но кто из вас занимался их разработкой? Сегодня мы разработаем с 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.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} не найдено`,
});
}
}
);
};
После регистрации, знакомства со своим холстом стоит познакомиться с изображением уровня которое нам необходимо будет изобразить на холсте. Для этого служит 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 вернет вам координаты промаха, чтобы вам было проще пристреляться. Готовой формулы расчета и перевода целевых координат пикселя в выстрел я приводить не буду ради сохранения спортивного интереса, хотя весь код расчета выстрела доступен на гитхабе и любая ИИшка расскажет вам секрет расчета правильного выстрела) Остановлюсь на механике выстрела более подробно. Первым делом мы проверяем, если у пользователя цвета, которые он передал для выстрела. Т.к. один и тотже цвет в выстреле может присутствовать более 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
После того, как вы настрелялись по уровню и готовы приступить к следующему, вам потребуется вызвать 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. Тестов как таковых не проводил, могут быть шереховатости, постараюсь максимально оперативно исправляться в комментариях) спустя пару дней после поста поделюсь историей нагрузки на сервер в комментариях.
n0isy
Похоже, оно уже лежит...
Придется карму статьями фармить. Эх...
UPD: заработало без TLS-сертификата
UPD2: на гитхабе неверный default branch
strokoff Автор
Катил хотфиксы был пару минутный даунтайм) все поднял
n0isy
Не уверен, что представленная формула максимизирует "совпадение" с уровнем. Такое ощущение что можно стрелять почти-белым. Я не прав?
strokoff Автор
Т.к цвета получаются рандомные, конечно можно выстрелить почти белым цветом. Но чисто белым не получится он приравнивается к прозрачному т.к вероятность сгенерировать чисто белый цвет крайне мала да и 1 цвет закрасит 1 пиксель. По этому в коде белые и прозрачные пиксели пропускаю при расчете. А вот черный или близко к черному сгенерировать и смешать достаточно легко.
n0isy
Как-то сбросить юзера / стату можно?
strokoff Автор
Нет, конечно технически это возможно, но специального инструментария нет. Через прямое удаление записи из бд могу удалить. у вас уже появилась такая необходимость?) как вариант завести второй акк себе
n0isy
Видимо особо нет, так как точность не учитывается в скоринге.
Есть баг при переходе на след. уровень: уровни лимитируются, но запись в субд кажется создаётся. Получается много 5тых уровней?
n0isy
В статистике баг вывода чисел с плавающей запятой https://0.30000000000000004.com/