Уууу, давно хотелось чего-то простого, смешного и без лишних заморочек. Чтобы мемов побольше и можно было с пацанами погонять. В итоге получились "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, который рисовал игровые элементы, и очень заманчиво было добавить редактор карт.

InGame Map Editor
InGame Map Editor

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

Editor Prototype
Editor Prototype

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

Flask-Admin GameMap Editor Widget
Flask-Admin GameMap Editor Widget

Бекенд на 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)


  1. pol_pot
    12.09.2025 01:19

    На хабре нет никакой премодерации?


  1. PerroSalchicha
    12.09.2025 01:19

    Напердолил

    Я так понимаю, слово "программировать" теперь можно использовать в качестве эвфемизма к этому?