Напердолил целую игру

от автора

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

Жду от вас тонны хейта. На интересные вопросы отвечу. Если статья зайдёт — напишу продолжение.

http://tankolini-napierdolki.com/?t=1


ссылка на оригинал статьи https://habr.com/ru/articles/946222/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *