Делаем многопользовательскую кроссплатформенную RPG с нуля
Когда-то давно меня очаровал ADOM. Я даже и близко не подошёл к прохождению игры, но мне нравилось бродить по этому миру, собирать предметы с эффектом, который не прочувствуешь, пока не используешь. Нравилось, что монстры в подземельях имели какие-то зачатки собственного интеллекта, подбирали с пола вещи и использовали их. Всё время что происходило и менялось, проходишь по тому же месту — смотришь кто-то уже подобрал с пола монеты. Или предметы в инвентаре испортились. Мир как будто живёт своей жизнью. В пещерах можно идти по коридорам, а можно наугад пробивать туннели заклинанием в надежде отыскать потайную комнату, оставляя на полу груду камней. Мир полный возможностей и способов взаимодействия.
Вдохновившись этими впечатлениями, я задумался, а может можно сделать что-нибудь своё, пропитанное подобным духом? И почему бы не попробовать сделать многопользовательскую игру с современной то скоростью интернета? Ведь это даёт особые ощущения, осознание того, что ты идёшь по туннелю, вырытому другим, настоящим, игроком. Или вот-вот встретишь его за углом.
Кроме того, хочется, чтобы мир был живым, развивающимся, логически связанным, чтобы вещи торговца, например, не генерировались под мой текущий уровень автолевелингом, а были им созданы из найденных или купленных у других игроков предметов. Игровой мир, где просто приятно побродить, открывая каждый раз что-то новое, а не просто биться с
другими игроками стенка на стенку.
Цель
Итак, наша цель — создать многопользовательскую онлайн игру с общим игровым миром. Это означает нам нужен централизованное серверное приложение, где будет крутится вся игровая логика.
Ключевые концепции:
Общий игровой мир — все игроки находятся в одном мире, могут встретится и взаимодействовать напрямую
Динамический игровой мир — НПС, монстры и игровые события функционируют даже в отсутствии игроков.
Большое количество способов воздействия на мир.
Свобода действий — никаких ведущих скриптов и искусственных ограничений. Возможности ограничены исключительно текущими навыками и способностями персонажа.
Прямое взаимодействие между игроками — можно общаться, торговаться и сражаться.
Косвенное взаимодействие между игроками — игроки могут взаимодействовать опосредованно, через изменение игрового мира. Например, постройка/уничтожение объектов общего пользования, прокладка новых маршрутов, обвал цен на товар.
Динамическое ценообразование — отсутствие свойства «стоимость» у предметов.
Для простоты делаем двумерную игру на дискретной сетке прямоугольной геометрии. Сеттинг возьмём банальный — фэнтезийное подземелье с гоблинами, скелетами, гномами и прочим. Графикой не увлекаемся, достаточно грубых рисунков, это уже должно быть не хуже традиционной ASCII графики Roguelik-ов.
Сервер будет авторитарным: клиент отправляет команды вида «иди вперед», «повернись», «подбери» и т.д. Сервер проверяет выполнимость действия и реализует его. Клиент не содержит никакой игровой логики, а только позволяет отправлять команды на сервер и визуализирует поступающую с сервера информацию.
Выбор инструментов
Обычно рекомендуется использовать готовые игровые движки, но после изучения описаний игровых серверов от Unity и Unreal Engine, они показались мне излишне громоздкими. А ковыряться в них и выкусывать лишнее — это ещё менее благодарное занятие, чем писать свой движок (тем более, что исходники не особо доступны). Так что было принято волевое решение написать свою игровой сервер «с нуля». Выбор языка программирования практически очевиден: нам нужен высокопроизводительный компилируемый язык общего назначения с библиотеками для сетевой работы, поддержкой объектно-ориентированного программирования, работой с указателями и кроссплатформенностью. Таким языком является C++.
Изначально я хотел написать клиентскую часть на чем-то вроде Delphi, но потом возникла интересная идея — сделать браузерный клиент. Преимущества такого подхода очевидны: нет необходимости заставлять пользователей скачивать что-либо, браузеры доступны на любом компьютере, в любой операционной системе, включая мобильные устройства. Однако есть и некоторые недостатки: JavaScript — не самый интуитивный язык, всё время старается работать с переменными как со строками и приходится его ловить за руку (к сожалению, потребовалось много времени на решение простых, казалось бы, проблем). HTML-вёрстка тоже далеко не однозначная вещь.
Клиент-серверный обмен
Как уже было сказано, клиент на первых порах будет браузерный. JavaScript поддерживает несколько способов работы с сетью: HTTP запросы через Fetch API, Server-Sent Events (SSE) и WebSockets. WebSockets позволяют установить двустороннюю связь без необходимости каждый раз проходить «тройное рукопожатие», но их реализация сложнее, отсутствует встроенная поддержка автоматического переподключения в случае разрыва связи, а также требуется дополнительная настройка для обработки кросс-доменных запросов. Поэтому выбор пал на SSE: клиентская часть реализуется всего несколькими строками на JavaScript, а на сервере достаточно определить запрос на установку SSE-соединения на основе наличия заголовка «event-stream» и отправить соответствующий заголовок в ответ. Единственным недостатком SSE является односторонняя связь. Поэтому мы организуем логику следующим образом:
При входе игрока в систему, клиент запрашивает разрешение на установку SSE-соединения.
Если условия входа выполнены, сервер отправляет подтверждающий заголовок, канал связи устанавливается и сервер начинает периодически отправлять данные клиенту-браузеру.
Браузер парсит поступающие с сервера данные, рисуя для игрока красивую (или не очень) картинку.
Чтобы отправить действие игрока на сервер, используем POST-запрос с помощью fetch. Сама команда обычно содержит 2–3 символа. Нам не нужен ответ от сервера, но, к сожалению, если сервер ничего не ответит, то в консоль браузера будет прилетать ошибка (это незначительная проблема, но всё же неприятно). Если кто-то знает, как избежать ожидания ответа в функции fetch — отпишитесь пожалуйста.
Чтобы не засорять канал лишним трафиком, клиент и сервер можно разместить на разных хостах, так обмен с сервером будет только по существу, а за всякими картинками браузер будет бегать на хост, где лежит клиент. Поэтому, подтверждая SSE-соединение, сервер должен отослать заголовок, содержащий правильную CORS-политику.
Использование Nginx на веб-серверах стало почти золотым стандартом. Как выяснилось, дефолтно Nginx буферизует отдаваемую сервером информацию, и клиенту приходил результат только через 2–3 игровых цикла. Пришлось это поправить в настройках:
proxy_buffering off;
Спасибо моему товарищу, который любезно предоставил мне виртуалку для сервера на своём хостинге и прикрутил Let’s Encrypt сертификат.
Серверная логика, игровой процесс возможности игры
Так исторически сложилось, что мне удобнее сидеть под виндой, сервера же обычно линуксовые. Поэтому пришлось писать в кроссплатформенном стиле (с использованием директив препроцессора), чтобы тестировать на своей машине, а затем отлаженную версию кода компилировать и запускать на сервере. Отличий в коде не так уж много, разные библиотеки для работы с сокетами: для винды WinSock2, а в линуксе это sys/socket.h, fcntl.h и другие. А ещё в юниксовых системах есть сигналы. Иногда функция send может отправить вашему приложению сигнал, и оно завершится. Поэтому вызывайте эту функцию с флагом MSG_NOSIGNAL или переопределите обработчик сигнала. В целом линукс творит чудеса: в тех случаях, когда игровой цикл на хорошем компьютере под виндой в среднем занимал 5–6 мс, аналогичный код на простенькой машине с линуксом выполнялся за 0.6–0.8 мс.
Общий игровой цикл сервера выглядит следующим образом:
Сначала все персонажи получают информацию о своём окружении (что видят, слышат, ощущают и т.д.). Для персонажей-игроков — эта информация рассылается клиентам.
Пауза, дополняющая время игрового цикла до заданной константы. Это необходимо, чтобы привязать элементарное действие к реальному времени, иначе гоблины будут бегать как сумасшедшие и игроки не будут успевать реагировать.
Считывание команд клиентов из неблокирующих сокетов.
Принятие решений (по своим алгоритмам для синтетических персонажей, по пришедшим командам — для игроков) и выполнение соответствующих действий.
Розыгрыш других компонентов игры: обвалы, активные объекты и прочее.
Возможности игры
Игровой мир представляет собой двумерную карту из квадратных клеток. В каждой клетке может находится не более одного объекта, но один объект может занимать несколько клеток. К объектам относятся персонажи, стены, постройки, бочки и т.д., т.е. всё, что непроходимо. Все объекты интерактивны, т.е. у персонажей-игроков (и у некоторых NPC) есть хотя бы один способ взаимодействия с объектом. Кроме этого, в каждой клетке может быть сколько угодно «напольных объектов» (лужи крови, кости персонажей, огонь, различные виды флоры и т.п.).
Игра про подземелье, поэтому среди объектов чаще всего будут попадаться «стены». Их можно рыть, если вооружиться, например, киркой. Рытьё стен не только открывает путь игроку, но и служит источником сырья: на пол падают камни, которые можно подбирать. Чаще всего — это пустая порода, но можно найти уголь, глину, руду и многое другое в зависимости от типа стены.
Персонажей можно разделить на 3 типа: монстры, NPC и игроки. Все они произошли от одного класса. Для живых персонажей написана функция зрения, которая обходит клетки карты в направлении взгляда с учетом прозрачных/непрозрачных объектов. Сейчас и гоблины, и гномы-NPC и игроки получают перечень видимых клеток карты с помощью данной функции. Для игрока эта информация записывается в буфер и отравляется клиенту, а для
остальных — анализируется ИИ.
Примеры видимых клеток. Картинка, отрисованная клиентом.
Искусственный интеллект
Искусственный интеллект персонажей отличается от класса к классу. Нежить «видит» во всех направлениях и бредёт к ближайшему живому существу, чтобы напасть. Гоблинское поведение сложнее — они умеют подбирать предметы с пола и атаковать, целенаправленно движутся к своей цели. Но самое хитрое поведение у гномов-NPC. Мне хотелось, чтобы из простых правил автоматически возникало сложное поведение как, например, у клеточных автоматов. А ещё меня вдохновила спектрумовская игра «Хоббит» и алгоритм построения термитника. Получилось, я считаю, очень неплохо. Гномы могут самостоятельно решать, что им не хватает руды и целенаправленно добывать её в нужных стенах, подбирать с пола то, что считают интересным, строить печи, выплавлять в них металлы и изготовлять орудия (крафт). Особенно интересно, что получились производственные циклы: гном выплавляет руду, затем идёт копать ещё руды и снова возвращается к той же печи или печи, построенной другим гномом, чтобы продолжить плавку — хотя явным образом я такое поведение не задавал.
Осторожно, тяжелая гифка
Зернистость изображений — артефакт конверсии из mp4 в gif
Строительство
В игре можно строить. Строительство разбито на 2 стадии: выкладка материала на пол и постройка из него объекта. Эти стадии абсолютно независимы. Можно выложить материалы на пол обычным способом, а потом строить. Можно строить, если материалы уже лежат на полу (гномы так и делают). Можно помогать строить как выкладыванием материала, так и непосредственно строительством. Подключаться можно на любой стадии, причём неважно, кто строит — персонаж-игрок или NPC, механизм помощи одинаковый. Это естественным образом добавляет способы взаимодействия в игре.
Торговля
В игре можно торговать как между игроками, так и с NPC.
Торговля реализована следующим образом. По-умолчанию, когда вы «наступаете» на NPC или другого игрока, ваши состояния переключаются на «подготовку к торговле» и оппонент начинает разворачиваться к вам. Как только вы окажетесь лицом к лицу, ваши состояния перейдут в «торговля» и сервер отправит вам инвентарь оппонента. Аналогично, если оппонент — живой игрок, то он получит информацию о вашем инвентаре.
Игроки могут отправлять «торговые предложения» — перечень предметов своего и чужого инвентаря на обмен. Далее процессы торговли между двумя игроками и между игроком и NPC несколько отличаются. В случае торговли с NPC ответ придёт уже в следующем игровом цикле: NPC либо согласится и обмен состоится, а торговля завершится; либо NPC возмутится вашим текущим предложением и будет ждать более интересного. Если ваш оппонент живой игрок, то он также может согласиться, а может отправить встречное предложение, которое сбросит ваше и теперь подтверждение потребуется с вашей стороны.
В игре реализовано динамическое ценообразование! Гномы-NPC оценивают предметы исходя из своих потребностей, распространенности материала в данной местности и текущего количества в инвентаре. Теперь не получится всучить NPC всякий хлам. Чтобы не гадать, что NPC готов предложить за ваши предметы или наоборот, что он хочет получить за свой предмет, вы можете оставить торговое предложение с одной стороны пустым и нажать соответствующую кнопку в клиенте. В ответ вы получите вариант, который точно удовлетворит NPC (если это возможно). Имейте ввиду, что такое предложение составляется жадным алгоритмом, поэтому если вариант NPC покажется вам слишком нескромным — попробуйте поправить его в свою сторону.
Навыки
Традиционно выделяют 2 подхода к навыкам:
накапливаешь универсальный опыт, по достижению уровня поднимаешь любые из навыков. Опыт обычно дается за убийство врагов или выполнение заданий (серии Fallout, Мight & Мagic). В Might & Magic даже хитрее — нужно ещё найти тренировочный зал.
прокачиваешь те навыки, которыми пользуешься (серии The Elder Scrolls, Аллоды).
В целом, первый вариант более популярный, но мне показался второй более правдоподобным и интересным. Для реализации необходимо ввести отдельный опыт для каждого навыка. Опыт растет с использованием навыка и при достижении достаточного количества, уровень владения навыком увеличивается. Поскольку навыков планируется много и не факт, что у отдельно взятого игрока будут все, решил попробовать ассоциативные массивы (раньше на C++ не имел с ними дело). И навыки и опыт хранятся в std::map
, где ключом является идентификатор навыка. В C++ есть ещё std::unordered_map
, который должен быть быстрее. Пока не соображу, что лучше в этом случае, если есть мысли — напишите.
Кстати, языки здесь тоже навыки — например, нужно знать эльфийский, чтобы понимать, что говорят игроки-эльфы. Мне кажется, что это интересно и добавляет определенный пласт в игровые механики.
Предметы и крафт
У каждого предмета задан тип и материал, причем допускаются множественные значения каждого из этих свойств. Указание материала позволяет обобщить некоторое поведение предметов, например, взаимодействие с огнём. Также «рецепты» постройки или крафта могут быть разнообразными и требовать определенных комбинаций предметов (как по идентификатору, так и по материалу), а также наличия навыка необходимого уровня.
Круговорот веществ
Важно поддерживать некий круговорот веществ, чтобы не допускать исчезновения одних материалов и появления избытка других. Так как рано или поздно все стены были бы срыты, были добавлены обвалы. Своды пещеры периодически обваливаются, образуя новые стены. Также на полу остается большое число камней, которыми NPC не интересуются. Чтобы не допустить их накопления, были введены специальные растения, способные расщеплять камни и поглощать минеральные вещества из них. Аналогично органические остатки расщепляются грибами. Такие механизмы круговорота помогают создавать динамичную и живую игровую среду.
Клиентская часть
Клиент должен уметь отображать приходящую с сервера информацию и отправлять игровые команды на сервер. Как уже было сказано, команды на сервер отправляются POST-запросами. После успешной команды «логин» устанавливается SSE-соединение, по которому сервер периодически отправляет строку с описанием всего, что увидел, услышал, почувствовал персонаж игрока в текущем игровом цикле. Главная задача клиента — распарсить эту строку и отобразить результат.
Основная часть полученной информации описывает содержимое клеток карты: объекты, персонажи, стены, напольные объекты. Для визуализации этого используется стопка дивов (некоторых с таблицами), лежащих друг на друге. Пол идёт в один див, напольные объекты — в следующий, объекты и стены — далее, затем персонажи, видимость и, наконец, звуки (смотри схему ниже).
Схематично: превращение строки с сервера в картинку в браузере
Единственная логика, реализуемая клиентом — это «туман войны». Видимость как таковая не посылается сервером, алгоритм работы прост: есть 3 типа видимости — «неизвестно» (темная картинка), «туман войны» (полупрозрачная картинка) и «полная видимость» (пустая картинка). Все клетки, которые были видимы в прошлый раз закрываются туманом войны (меняется src картинки в слое видимости), а клетки, информация о которых поступила в текущем цикле, становятся видимыми.
Мобильный клиент
Раз уж мы написали браузерный клиент, давайте попробуем написать версию для мобилки, кажется, что делать тут не так уж много. Однако для мобильных приложений нужно учесть, что экран телефона обычно небольшой и имеет вертикальную ориентацию. Поэтому попробуем сделать так, чтобы персонаж находится все время в центре экрана и смотрел вверх, а при движении и поворотах двигалась/поворачивалась карта, а не персонаж. Двигать карту тривиально, а вот для поворотов вспомним уравнения перехода между системами координат:
где индекс 0 обозначает старые координаты, а α — угол поворота координатных осей. Естественно, нет нужды каждый раз вычислять синусы/косинусы, достаточно держать таблицу с готовыми значениями в зависимости от угла. При перемещении содержимого клеток таблицы из одних ячеек в другие приходится использовать буфер, чтобы не затереть старые данные.
Повернуть клеточки в таблицах — это ещё полбеды. Надо повернуть персонажей и напольные объекты, которые лежат отдельными картинками. При этом, если размер картинки больше, чем 1×1 клетки, то нужно вращать не её координаты, а координаты её центра (см. рисунок ниже).
Положение объекта при повороте карты на 90°, реализованное наивным образом и при вращении и восстановлении от координат центра
Изначально я реализовал управление персонажем с помощью свайпов (влево/вправо для поворота и вверх/вниз для движения вперед). Однако такой способ управления не очень прижился, и по просьбам тестеров сделал версию, где нужно нажимать клетки рядом с персонажем, чтобы поворачиваться или идти вперёд. В мобильном клиенте я ещё добавил небольшую пиктограмму-анимацию текущего действия игрока и другие украшательства, сейчас мобильная версия выглядит даже интереснее десктопной:
Осторожно, тяжелые гифки
Мобильный клиент
Зернистость изображений — артефакт конверсии из mp4 в gif
Как бы это ни парадоксально звучало, с точки зрения вэба, клиентская часть — это статика, поэтому её можно разместить на каком угодно бесплатном хостинге (пока не придумаю хорошее название и не куплю доменное имя). Попробовал 2 варианта: Vercel и Netlify, последний мне понравился больше, не сочтите за рекламу.
Еще один плюс браузерного клиента — простота моддинга. Достаточно подменить путь к картинкам и у нас версия с другой графикой. В принципе, любой желающий может поправить код клиента под себя.
Другие клиенты
В качестве разминки написал ещё и простенький питоновский клиент для своей игры без графики, где вся информация с сервера отображается текстом. Выглядит как старые текстовые адвентуры.
Питоновский текстовый (слева) и браузерный (справа) клиенты
Ещё можно сделать какой-нибудь простенький 3Д-клиент, вроде такого как описан здесь, тогда можно получить что-то вроде Wolfenstein 3D или старых M&M. Если вдруг кто знает движок для подобного отображения 2-мерных координат в 3Д и умеющий работать с сетью — отпишитесь в комментариях или ЛС.
Prediction-correction
Для сетевых игр приходится вспомнить, что информация распространяется хоть и с большой скоростью, но всё-таки конечной. Например, команда клиента, ушедшая на сервер, дойдет до него только через несколько миллисекунд. Чтобы игрок сразу увидел какой-то результат некоторые игры идут на обман — рисуют прогнозируемый результат, а когда, наконец, приходит информация с сервера либо подтверждают состояние клиента, либо откатывают. Данный метод называется prediction-correction, пример реализации.
Такой подход возможен только в случае, если клиент обладает достаточным количеством информации о текущем состоянии игры на сервере. В стандартных MMORPG или 3D-шутерах, где карта обычно фиксирована, а динамические объекты (монстры и предметы) рассыпаны по ней, это достижимо. В случае полностью динамического мира такие прогнозы чреваты. Шагнули вперед и раньше там был коридор? А теперь путь преградил обвал. Взмахнули киркой? Предсказываем что будем рыть и рыть? А на самом деле, с другой стороны другой игрок роет туннель, и мы сейчас встретимся? Думал, что ударил по гоблину, а сзади кто-то подкрался и убил? В такой постановке предсказания делать фактически невозможно, придётся постоянно корректировать решения, да ещё и тащить серверную логику на клиент.
В то же время, современные скорости интернета, возможность отправлять команды и получать информацию с сервера параллельно, а также небольшой объем передаваемых данных позволяют достичь приемлемой задержки и без применения механизма предсказания-коррекции. Для данного проекта я решил, что «честный» результат важнее, а несколько миллисекунд не такая уж большая задержка для игр, которые изначально были пошаговыми.
Криптография
Безопасность соединения обеспечивается протоколом HTTPS, но мне понадобилась криптография и в других местах. Как говорят умные люди: «Never do cryptography by yourself», поэтому воспользуемся библиотекой openssl для лунуксовой версии (в виндовой криптография не используется). С помощью этой библиотеки хэшируем пароли, чтобы в случае взлома сервера и утечки паролей злоумышленникам попали только их хэши (я понимаю, что вряд ли кому-то интересно ломать сервер ради паролей к игре, но всё же).
Второе, где полезна openssl — это генерация случайных токенов. Предположим, что мы указали правильный пароль, авторизация прошла успешно, мы залогинились в игру и можем отправлять команды серверу. Однако после авторизации ничто не мешает кому-то другому отправлять команды от нашего имени, ведь проверка пароля уже выполнена. Чтобы защититься от подобных ситуаций, каждый раз залогинившемуся клиенту выдается случайный токен, который необходимо отправлять вместе с идентификатором игрока. Сервер проверяет, является ли этот токен действительным для данного игрока. Для
генерации такого токена используются функции из вышеупомянутой библиотеки.
Краткий итог
Текущие возможности:
Онлайн
Инвентарь и экипировка
Сражение (PvP и PvE)
Торговля (с игроками и NPC)
Обмен текстовыми сообщениями
Навыки (включая игровые языки)
Рытье стен
Металлургия
Крафт
Строительство
Продвинутый ИИ
Динамическое ценообразование
Суперудары (появляются при достижении определенного уровня навыков)
Динамические объекты
Динамические напольные объекты
Обвалы
Возможные направления дальнейшего развития:
Более сложная боевая система (атака, защита, отражение ударов и т.д.)
Ещё более хитрый ИИ
Разного рода «поля», например, температура и влажность. Самопроизвольные события, завязанные на них, например, конденсация луж при высокой влажности.
Многоуровневая карта
Где посмотреть?
Ссылки под спойлером