
Уууу, давно хотелось чего-то простого, смешного и без лишних заморочек. Чтобы мемов побольше и можно было с пацанами погонять. В итоге получились "TANKOLINI NAPIERDOLKI".
Старый добрый монохромный экран, тетрис, мультиплеер и редактор карт для каждого. С другой стороны — всё на канвасе, с вручную отрисованными пикселями, без всяких ассетов и движков. Python на бэке, PostgreSQL для карт и Redis для игровых комнат. Обо всём этом — в статье.
Сначала был фронт
Не знаю почему, но я решил не рисовать объекты в графическом редакторе, а делать отрисовку прямо в игре, на канвасе. Начал с элементарной единицы — монохромного пикселя. Выглядит это так:
BackgroundBrick.prototype.render = function (context2d) {
// a + d + b + c + b + d + a = 2a + 2d + 2b + c = 2*7.5% + 2x5% + 2x10% + 55% = 100%
//
// |------------------------------------------------|
// | |-------------------------------------| |
// | | | |
// | | |----------------------| | |
// 7.5%| 5% | 10% | 55% | 10% | 5% |7.5%
// --->|<-->|<----->|<-------------------->|<---->|<--->|<---
// a | d | b | c | b | d | a
//
// 2a - space between pixels
// b - outer border of pixel
// d - filled body rectangle
//
// Center at the point (a + b + d + c/2, a + b + d + c/2)
if (this.color === colorScheme.regular) {
context2d.strokeStyle = elementColor.blockBorderColor;
context2d.fillStyle = elementColor.blockCenterFillingColor;
} else if (this.color === colorScheme.active) {
context2d.strokeStyle = elementColor.blockBorderColorActive;
context2d.fillStyle = elementColor.blockCenterFillingColorActive;
} else if (this.color === colorScheme.highlight) {
context2d.strokeStyle = elementColor.blockBorderColorActive;
context2d.fillStyle = elementColor.blockBorderColorHighlight;
} else if (this.color === colorScheme.highlightContrast) {
context2d.strokeStyle = elementColor.blockBorderColorHighlightContrast;
context2d.fillStyle = elementColor.blockBorderColorHighlightContrast;
}
let d = Math.trunc(this.__width*5/100);
let b = Math.trunc(this.__width*10/100);
let c = Math.trunc(this.__width*55/100);
let a = (this.__width - 2*d - 2*b - c)/2
let strokeWith = 2*d + 2*b + c;
context2d.lineWidth = d;
context2d.strokeRect(this.newX() + a, this.newY() + a, strokeWith, strokeWith);
let fillStart = a + d + b;
context2d.fillRect(this.newX() + fillStart, this.newY() + fillStart, c, c);
};
Потом такой кирпичик кэшируется и дальше используется уже в готовом виде. На основе таких чёрных и зелёных элементов можно строить более сложные объекты.
TankFigure.prototype.render = function (context2d) {
context2d.translate(this.elementWidth()/2, this.elementHeight()/2);
context2d.rotate(this.direction);
context2d.translate(-this.elementWidth()/2, -this.elementHeight()/2)
let indicatorColor = this.friend ? this.color: colorScheme.regular;
let bricks = [
//top
new BackgroundBrick(1, 0, this.__width, this.__height, this.color),
//center
new BackgroundBrick(0, 1, this.__width, this.__height, this.color),
new BackgroundBrick(1, 1, this.__width, this.__height, this.color),
new BackgroundBrick(2, 1, this.__width, this.__height, this.color),
//bottom
new BackgroundBrick(0, 2, this.__width, this.__height, this.color),
new BackgroundBrick(2, 2, this.__width, this.__height, this.color),
];
if (this.friend) {
bricks.push(new BackgroundBrick(1, 2, this.__width, this.__height, indicatorColor))
}
bricks.forEach(function (pixel) {
pixel.render(context2d);
}.bind(this));
}
Это, соответственно, уже в относительных координатах, где точка — это наш кирпичик, коих в игре 50 по ширине и 50 по высоте. Дальше объект снова кэшируем, включая все возможные углы поворота, и используем в игре.
Очень важно отметить, что проверка правил игры, отрисовка графики и управление происходят в разных сервис-воркерах. Иначе любое нажатие кнопок блокировало бы отрисовку и вызывало лаги. Общение между ними идёт через отправку сообщений. Игровой движок сам по себе следит только за положением объектов, движением пуль и соблюдением правил игры. Если происходит новое действие и оно валидное, он отправляет данные на графический движок, который уже отрисовывает объекты на канвасе.
// Вызов из главного треда
page.engine.postMessage({
name: 'bulletStart',
params: [
message.data.user_id,
message.data.uid,
{
x: message.data.x,
y: message.data.y,
direction: rotationAngle[message.data.direction]
}
]
});
// Вызов из треда игрового движка
GameEngine.prototype.bulletStart = function (tankName, bulletName, positionCopy){
let tank = this.__tanks[tankName];
// проверка валидности ситуации
// ....
// ситуация валидная, отправка на рендер
postMessage({
name: 'bulletAdd',
params: [bulletName, positionCopy]
});
setTimeout(this.bulletFly.bind(this), 50, newBullet);
}
// тред графического движка
Scene.prototype.bulletAdd = function (name, positionCopy) {
this.__bullets[name] = new BulletFigure(
positionCopy.x,
positionCopy.y,
this.blockWidth,
this.blockHeight
);
}
// дальше рендер по requestAnimationFrame
Редактор карт
Я постарался разделить код, связанный с игровым процессом и отрисовкой графики. В итоге у меня получился прототип Scene, который рисовал игровые элементы, и очень заманчиво было добавить редактор карт.

Достаточно было немного изменить управление — и теперь можно создавать свой монохромный арт прямо в игре, а затем запускать игровые комнаты с только что нарисованной картой.

Получилось очень удобно и насколько переиспользуемо, что я сделал на базе этого редактора за один вечер еще один отдельный, прямо в Flask-Admin для создания постоянных карт.

Бекенд на FastAPI
Сам по себе я не фронтендщик, моя специализация — бэкенд. Обычно пишу его на Python. Честно говоря, Python не даёт особых плюсов в этом плане — лучше было бы делать это на C/C++ или Rust, но я двигаюсь в этом направлении уверенно.
В целом на бэке всё просто: регистрация и авторизация (включая вход и пополнение счёта через Telegram), плюс передача данных через веб-сокеты. Я особо не заморачивался и отправляю данные в старом добром JSON. При желании можно было бы слать и бинарные данные, но необходимости в этом пока не вижу.
Базу данных для пользователей я выбрал PostgreSQL просто потому, что почти во всех проектах её использую, неплохо знаю и она для меня привычнее. Но здесь особой разницы нет: информации немного — только профили пользователей и сгенерированная карта, которую, в принципе, можно было бы сохранять на диск как JSON-файл и раздавать как статику через Nginx.
Попадания и движения
Интересное начинается на бэке именно с синхронизации данных. Обновлять в PostgreSQL данные пользователя при каждом выстреле, движении и т. д. — не лучшая идея, так как эта база для этого не предназначена. Решить задачу можно и через неё, но зачем?
Для этого я выбрал Redis. Он быстрый, хранит всё в памяти — идеально. Каждого игрока можно хранить под своим ключом и обновлять его положение напрямую. На первый взгляд может показаться, что возможны гонки, но игровой процесс устроен так: событие (движение или выстрел) отправляется на сервер, и только после подтверждения клиент продолжает работу. Пока данные «в пути», клиент заблокирован. Фактически, действия выполняются синхронно, и параллельных обновлений у одного и того же пользователя не возникает.
С движением и выстрелами всё понятно, а вот с регистрацией попаданий могут быть нюансы. Как определить, что пользователь А попал в пользователя Б, если у одного пинг 20, а у другого 80, и первый видит попадание, а второй — нет? В реальности все видят разные картинки.
Подходов к решению несколько: можно считать всё на сервере, можно на клиенте. Например, сервер мог бы хранить всю карту, рассчитывать траектории пуль и «выравнивать» пинги, чтобы картинка у всех была одинаковой. Но для такой простой игры это избыточно. Есть и другие варианты, но я выбрал наиболее простой и при этом частично защищающий от читов — голосование.
Если 50% игроков в комнате сообщают, что пользователь А попал в Б, значит, скорее всего, так и есть. Осталось только придумать, как это посчитать. Если клиенты будут отправлять запросы на инкремент счётчика попаданий, то возникнет гонка. Нужно синхронизировать обновления. Здесь помогают Lua-скрипты в Redis. Их особенность в том, что одновременно выполняется только один такой скрипт — то есть их выполнение можно считать синхронной транзакцией.
local status = redis.call('get', '{bullet_status}');
if (status == '1')
then
return nil;
end;
local total_players = redis.call('hget', '{room_stats}', 'total_players');
if (total_players == false)
then
total_players = 0;
end;
local consensus_amount = total_players / 2;
if (consensus_amount < 1)
then
return nil;
end;
redis.call('sadd', '{bullet_hits}', '{user_id}');
redis.call('expire', '{bullet_hits}', 60);
local confirmations = redis.call('scard', '{bullet_hits}');
if (confirmations < consensus_amount)
then
redis.call('set', '{bullet_status}', 0);
redis.call('expire', '{bullet_status}', 10);
return nil;
end;
redis.call('set', '{bullet_status}', 1);
redis.call('expire', '{bullet_status}', 10);
redis.call('hset', '{room_players}', '{killer}', '{killer_data}');
return 1;
По сути, этот скрипт возвращает 1
, если пользователь был убит, и nil
, если нет. При этом он синхронно увеличивает счётчик попаданий.
В таком подходе есть свои нюансы. Например, при игре 1 на 1 попадание должно быть подтверждено с обеих сторон. Поэтому при пинге в районе 80 можно довольно часто получать баги: либо попадание не засчитывается, хотя оно было, либо наоборот — фиксируется там, где его не было.
Заключение
Пока тестировал приложение, успел влюбиться в фоновую озвучку — и поностальгировал, и посмеялся.
С технической точки зрения это не самое простое, что можно сделать за одни выходные, но и далеко не самый сложный проект.
Вряд ли опытные разработчики найдут здесь что-то новое — скорее наоборот, им наверняка многое не понравится. Но я писал это для молодых разработчиков и хочу вселить в них искру здорового развития: старайтесь писать код без помощи AI, не копируйте вслепую всё, что он вам выдаёт. Читайте документацию, интересуйтесь сложным. Накапливайте знания, иначе с AI они будут «пролетать мимо ушей».
Жду от вас тонны хейта. На интересные вопросы отвечу. Если статья зайдёт — напишу продолжение.
Комментарии (2)
PerroSalchicha
12.09.2025 01:19Напердолил
Я так понимаю, слово "программировать" теперь можно использовать в качестве эвфемизма к этому?
pol_pot
На хабре нет никакой премодерации?