
Уууу, давно хотелось чего-то простого, смешного и без лишних заморочек. Чтобы мемов побольше и можно было с пацанами погонять. В итоге получились «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 они будут «пролетать мимо ушей».
Жду от вас тонны хейта. На интересные вопросы отвечу. Если статья зайдёт — напишу продолжение.
ссылка на оригинал статьи https://habr.com/ru/articles/946222/
Добавить комментарий