Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #125, МАЙ 2009 г.

Темное искусство игродела, часть 4: multiplayer. Разрабатываем клиент и сервер для многопользовательских баталий

Юрий «yurembo» Язев (yazevsoft@gmail.com)


Любая разработка имеет начало и логический конец. Наша программа не стала исключением. Пройдя небольшой цикл статей, мы с тобой создали очень даже неплохую игру с поддержкой всех современных технологий, реализованных в DirectX (загрузка Х-объектов, 3D-графика, звуки в нужных местах, быстрый контроль и др.). Но для полноценного гейминга не хватает еще одного компонента. Конечно же, это мультиплеер.

Выбираем «скелет»

Всем прекрасна библиотека Dark GDK, но в каждой бочке меда есть ложка дегтя. Так и с рассматриваемой либой: чтобы с ее помощью реализовать мультиплеер, нужно не по-детски напрячься. В конце концов, мне это надоело (и хорошо, что надоело, а то пришлось бы писать кода раза в 4 больше, чем сейчас), и я стал искать другие решения. И, как ни удивительно, выход был найден: в виде, по меньшей мере, пары специальных протоколов, используемых в разработке многопользовательских игр. Протоколами такие вещи можно назвать с большой натяжкой, правильнее их звать интерфейсами к протоколу DirectPlay, поскольку для обмена сообщениями в Dark GDK (да и, собственно, в DirectX) используется именно он. Но для простоты будем называть их протоколами, так как они используют свои правила для передачи данных с помощью DirectPlay.

Первый из этих протоколов – это MultiSync (смотри http://forum.thegamecreators.com). Несмотря на то, что продукт вполне работоспособен и более удобен в применении, чем DarkGDK, мне он не очень понравился. Продолжив поиски, я наткнулся на очень примечательную вещицу под названием MikeNet. Испытав этот протокол в демо-приложениях, я решил использовать в игре его.

MikeNet позволяет легко создавать клиент-серверные приложения: как серверы, так и клиенты. Работая с ним, не надо беспокоиться о сокетах и других сетевых заморочках - просто формируем пакет и отправляем. Интересной возможностью является одновременная работа сразу по двум протоколам: TCP и UDP; для этого при соединении с сервером, как на клиенте, так и на сервере открываются по два локальных порта. Через них и идет последующая передача данных. Разработчику не надо грузиться с определением открываемых портов, MikeNet возьмет всю работу на себя и сделает ее незаметно для конечного пользователя.

MikeNet

Скачать MikeNet можно со страницы форума сайта компании The Game Creators (http://forum.thegamecreators.com). Последняя версия на момент написания статьи – 1.0.6, ее-то, собственно, yurembo и использовал при разработке программ. К слову, Майк - автор библиотеки – довольно оперативно все обновляет, поэтому не исключено, что, когда ты будешь читать этот материал, уже выйдет новая версия библы (кури блог Майка на предмет обновлений).

После скачивания и распаковки архива в нем обнаружится несколько файлов и каталогов. Чтобы установить протокол, найди и запусти файл MikeInstall.exe (только обрати внимание, что ты ставишь версию для С++ :)). Должно появиться окно DOS-сеанса следующего вида.

Инсталлятор сам определит, куда установлена VS и скопирует два файла, необходимых для компиляции приложений, использующих MikeNet. Однако в некоторых случаях он может работать неправильно. При появлении глюков собственноручно скопируй файл MikeNet.lib в подпапку VC\lib папки, куда установлена студия, и файл mikenet.h – в подпапку VC\include.

Чтобы приаттачить установленную библиотеку к компилятору, запусти студию (загрузи проект, в котором собираешься использовать средства MikeNet) и открой свойства проекта (например, Project -> DarkRobot Properties). В свойствах последовательно разверни списки: Configuration Properties -> Linker, а затем щелкни по пункту Input.

В правой части окна, в строку зависимостей (Additional Dependencies), добавь названия двух библиотек: MikeNet.lib и WS2_32.lib. Для правильной компиляции проделай следующий трюк: оставаясь в свойствах, разверни список C/C++, щелкни на пункте Code Generation, и в правой части, напротив пункта Runtime Library, из ниспадающего списка выбери Multi-threaded (/MT). Это означает, что приложение будет использовать многопоточную, но статичную версию библиотеки времени исполнения.

Все настройки произведены! Смело закрываем окно свойства и двигаемся дальше.

Сервер

Наконец, компилятор настроен, и можно переходить к самому приятному в жизни кодера – программированию. Сначала мы разработаем приложение-сервер. В нашем случае сервер будет заниматься только передачей данных, то есть будет принимать данные от одного клиента и рассылать их всем остальным. Никаких особенных вычислений на его стороне производиться не будет – всю работу мы возложим на клиента. Перечислю, какие данные нужно передавать. Во-первых, координаты (каждого) клиентского робота и угол поворота.

Во-вторых, был ли произведен выстрел данным конкретным роботом. Другими словами, нам не надо передавать координаты каждой ракеты. Достаточно лишь узнать, совершил робот выстрел или нет. Так как все координаты и угол поворота для данного робота у всех клиентов одинаковы, то и ракета вылетит из определенных координат (позиции робота) и приземлится в одинаковой позиции или поразит одну и ту же цель (поскольку данные синхронизированы!). В-третьих, надо передать количество жизней (для чего это нужно – разберем позже, во время разработки клиента).

И, в-четвертых, необходимо передать имя клиента и количество фрагов (набранных убийств). Эти данные понадобятся для ведения сервером статистики, позже мы это разберем. Создай в VC++ новый проект. В качестве шаблона выбери Dark GDK – Game. Во все проекты, которые используют библиотеку MikeNet, надо добавлять такой заголовочный файл: <MikeNet.h>. Теперь можешь открыть с диска проект сервера (автор назвал его DarkServer, по аналогии с DarkRobot), исследовать по комментариям код и начинать его переписывать. Ну а yurembo тем временем расскажет тебе о коде более подробно.

После подключения заголовочных файлов идут объявления констант, назначение которых ясно по комментариям, и структуры, состоящей из двух членов: первый, хранящий имя клиента и второй - количество фрагов. Далее следует главная функция программы, в ней и происходит все действо. Операции, относящиеся к настройке и перерисовке экрана, мы рассматривать не будем (поднимай предыдущие статьи). Первая интересующая нас функция – это mnSetLocal с четырьмя параметрами. Первый параметр – ip-адрес для tcp подключения. Если его оставить пустым «» (как в нашем случае), то программа сама определит его, это будет ip-адрес машины, на которой запущен сервер. Вторым параметром следует номер порта, на котором будет ожидание tcp подключения. Затем идут два параметра, аналогичных приведенному выше описанию, за исключением того, что вместо tcp используется протокол udp. Несмотря на то, что номера портов явно определяются программистом, после соединения клиента с сервером MikeNet определяет их и сам (смотри описание MN выше). К слову, функция mnSetLocal при успешной работе возвращает единицу, иначе – 0 или -1. Такая проверка выполняется после ее вызова с последующим выводом диагностических надписей.

Итак, в случае успеха мы запускаем сервер командой mnStartServer с четырьмя параметрами: максимальное количество клиентов, которые могут подключиться, максимальное количество udp-операций, которые клиент может отправить в одном udp-пакете. Третьим параметром определяется способ раздачи пришедших udp-пакетов. Доступно два способа: UDPMODE_PER_CLIENT и UDPMODE_PER_CLIENT_PER_OPERATION. Когда используется первый способ, от каждого клиента запоминается только один пакет, пришедший последним, более старые – удаляются. В случае второго запоминаются пакеты со всеми операциями (а не только последние), пришедшие от каждого клиента. Последний используется, когда клиент может отправить несколько разных пакетов (с разными операциями), например, как в нашей игре. Последний параметр задает количество потоков, используемых при получении и передаче данных. Для максимального быстродействия системы это число должно зависеть от количества процессоров, установленных на машине, на которой запущен сервер. Однако если поставить 0, то MikeNet сам определит количество установленных ЦПУ и будет использовать данное число. Это прекрасный вариант - можно без изменения и перекомпиляции запускать наш сервер на разных компьютерах, и везде его производительность будет на высшем уровне! В нашей программе используется 0.

Далее по коду идет объявление нескольких переменных нашего структурного типа, затем цикл, – в нем члены всех объявленных ранее структур обнуляются. Теперь мы, наконец, попадаем в главный цикл. В нем постоянно проверяются порты для выявления пришедших данных. Кроме того, в самом начале цикла есть задержки. Они нужны, чтобы программа не зависла и смогла обрабатывать другие, отличные от проверки портов, процессы. Функция mnClientJoined прослушивает порт в целях выявления вновь подключившегося клиента. Параметров она не имеет - если возвращаемое значение больше нуля, значит, подключился новый клиент, и надо отобразить его данные: id, tcp ip, tcp port, udp ip, udp port. После чего надо сообщить новому клиенту об уже подключенных, это совершается в цикле. Используемая здесь функция mnClientConnected проверяет, подключен или нет клиент, id которого передается параметром.

В результате она возвращает единицу, если результат положительный. Всем подключенным клиентам сообщается о новеньком. Функция mnClientLeft проверяет, не отключился ли какой-либо клиент и, если она возвращает значение больше 0, значит, клиент с таким идентификатором (возвращенное значение) недавно отключился. Об этом прискорбном событии мы также информируем играющую компанию посредством текстового сообщения. Далее идет цикл по всем клиентам, в который вложен еще один цикл по всем udp-операциям (в нашей программе их две). И уже в нем происходит изъятие из очереди данных, и проверка на приход отправленного клиентом пакета. Это осуществляется функцией mnRecvUDP, у которой два параметра: 1 – id клиента, от которого ожидается пакет, 2 – номер операции. Также функция возвращает 1 в случае удачи (получения пакета), 0 – в случае отсутствия посылки и -1 – при появлении ошибки. Если пакет пришел, то его надо распотрошить и отправить полученные данные всем клиентам за исключением того, от кого он пришел. Деформируем пакет в том же порядке, в котором формировали (смотри код клиента и сервера). Это делается с помощью функции mnGet* (где * - тип данных). После де-формирования пакета (сохранения всех данных в переменных), с помощью функций mnAdd* (где * - тип данных) формируем новый пакет. Эти функции в качестве параметра принимают значение определенного типа данных. Наконец, посредством функции mnSendUDPAll отправляем сформированный пакет всем клиентам. У нее три параметра:

  1. сохранять или нет отправленный пакет;
  2. блокировать или нет команду до тех пор, пока пакет не будет всем отослан;
  3. здесь указывается клиент, который исключается из списка рассылки (надо указать того клиента, от которого пришел пакет с обрабатываемыми данными).

Кроме того, в этом цикле в переменную deadcount сохраняется наибольшее количество набранных игроком фрагов. После окончания цикла переменная проверяется, и, если она больше или равна установленному лимиту фрагов, которые необходимо набрать для победы, тогда формируется новый udp-пакет (2-ого типа). В него записываются имена и количество набранных каждым клиентом фрагов, – и пакет также отправляется всем клиентам для вывода (на их стороне) таблицы отчетности по игре (кто, сколько фрагов набрал) – позже обязательно рассмотрим. Таким образом, в нашей игре реализована мультиплеерная баталия типа Free for All – все против всех или каждый за себя.

Клиент

Чтобы превратить наш синглплеер в мультиплеер, в его код нужно внести очень немного изменений. Тем более, в мультиплеере используется тот же движок, что и в одиночной игре. Поэтому yurembo решил объединить две версии в одну игру (выбирается в меню). По этой же причине максимально возможное количество клиентов равно пяти - количеству роботов в сингплеере. Изменения касаются только способа управления врагами: в однопользовательской игре врагами руководит функция искусственного интеллекта, а в многопользовательской – принятые данные (отправленные от клиентской части оппонента, если роботом управляет геймер). Все сделано для того, чтобы можно было оставить движок прежним, добавив только поддержку мультиплеера.

DarkRobot Multiplayer Game

Загрузи свою финальную версию синглплеера, потому что именно ее мы будем дорабатывать. Кроме того, ты можешь загрузить уже готовый мультиплеер с диска, чтобы сверять результаты.

В основном изменениям подверглись четыре файла: Main.cpp, Robot.cpp, Robot.h, Game_Obj.h (в последних двух изменения очень несущественны). Начнем обзор модификаций с файла Main.cpp. Здесь добавилась важная глобальная переменная gamemod, в ней отмечается текущий режим игры: 0 – режим не установлен (режим начального меню), 1 – синглплеер, 2 – мультиплеер. Пока в нашей игре реализован только один многопользовательский режим, поэтому, если хочешь еще режимы (например, дуэльный режим или захват флага), то удобнее всего добавить индикатор режима в эту переменную. Поэтому, кроме всего прочего, автор приготовил для тебя легко расширяемый механизм игрового движка. Также добавлены константы для определения типа tcp и udp пакетов (о них ты читал выше и можешь узнать больше из комментариев к программе) и лимит фрагов.

После запуска игры перед геймером появляется консольное меню выбора режима. Почему консольное? Потому что yurembo не хотел отходить от начальной задумки (самая первая версия – если помнишь, когда выводилась надпись, повествующая о загрузке уровня). К тому же, ему было лень рисовать картинки для фона и кнопок меню (была бы его воля, он кроме написания кода вообще ничем бы не занимался). В этом (и во всех последующих) меню для выбора нужного пункта надо нажать указанную рядом с ним клавишу. Первоначальное меню выводит самописная функция SelectGameType. В ней обнуляется переменная gamemod, то есть игра переводится в режим меню. Затем запускается цикл, который «вращается», пока с помощью клавиш не выбран режим игры. Здесь интересна только одна функция – это dbScanCode, которая возвращает номер нажатой клавиши. Номера стартуют с единицы, начиная с клавиши с буквой «Е».

Если выбрать синглплеер, то он и загрузится – не будем рассматривать этот путь, поскольку все о нем знаем! С другой стороны, если выбрать мультиплеер, то появится новое меню, где предлагается ввести имя геймера, которое запоминается в глобальной переменной и сохраняется на протяжении всего сеанса игры. За работу этого меню ответственна функция GetUserName. Пустое имя ввести нельзя. Когда имя будет введено, появится следующая страница меню, где надо будет ввести ip-адрес сервера.

Эту страницу выводит и обслуживает функция makeConnect, которая после получения ip с помощью функции mnConnect пытается подключиться к серверу. Функции mnConnect передаются шесть параметров: ip-адрес сервера, ожидающего подключения по tcp; номер открытого tcp порта на этом сервере; ip-адрес сервера, ожидающего подключения по udp; номер открытого udp порта на этом сервере; время ожидания (в секундах) ответа от сервера; количество потоков (этот параметр аналогичен одноименному в функции mnStartServer и рассмотрен выше). В случае успеха подключения данная функция возвращает 1, 0 – по истечении времени ожидания и -1 – в случае ошибки. Анализируя возвращаемое значение, наша функция makeConnect выводит диагностические данные. Несмотря на то, что для tcp- и udp-подключений ip-адреса передаются в разных параметрах, MikeNet не может работать с двумя разными серверами, поэтому необходимо указывать ip-адрес одного и того же сервера.

После удачного соединения загружается игра. Замечу, что в любом из меню всегда есть возможность покинуть его, вернувшись в начальное. Такое взаимодействие между функциями осуществимо благодаря возвращению (почти) каждой функцией булевского значения: успешно или нет, выполнена та или иная операция. Во время загрузки многопользовательской игры происходит такая же инициализация, что и при одиночной. Так как главными действующими лицами являются роботы, их класс должен содержать переменную-член – идентификатор мультиплеерной игры. Это сделано в связи с простыми рассуждениями: если робот жив, он в онлайне (клиент в онлайне), а если убит, тогда в отключке. Еще одна переменная, добавленная в класс роботов – это cnumber, которая хранит номер клиента в порядке подключения к серверу.

В связи с тем, что эти и другие переменные закрыты, в класс также добавлены функции, изменяющие и возвращающие значения этих переменных. Кратко их рассмотрим: функции SetNumber и GetNumber соответственно устанавливают и возвращают значение переменной cnumber, SetLifeCount и GetLifeCount устанавливаю и возвращаю количество жизней. GetWinCount возвращает количество набранных фрагов, а ZeroWinCount обнуляет эту переменную. И последняя пара: SetMultiplayerMode и GetMultiplayerMode соответственно устанавливают и возвращают режим игры (для переменной-члена робота). Что еще касается файла Robot.cpp, – конструктор и деструктор робота претерпели небольшие изменения: роботы-враги рождаются невидимыми и неактивными. Деструктор проверяет – если уничтожается робот главного персонажа, то клиент отключается от сервера (с помощью функции mnFinish, не имеющей параметров). Стоит заметить, что именно в функции-члене класса роботов Die увеличивается счетчик фрагов. Для этого осуществляется проверка, кем был убит тот или иной оппонент, и, если убийца – это главный персонаж, ему добавляется 1 фраг. Подсчеты ведутся для каждого клиента отдельно, но благодаря статистике, которую ведет сервер, данные синхронизируются.

Вернемся в Main.cpp. Кроме всего прочего, теперь, при создании главного робота, его местоположение выбирается случайным образом. И хотя враги создаются с заданными по умолчанию координатами, после подключения очередного клиента и создания его робота (на его машине), один из врагов (первый неактивный) активизируется и входит в игру с координатами робота-оппонента.

Ниже по коду мы попадаем в основной цикл, где вместо взаимодействия с объектами сначала идут сетевые работы, то есть проверки на приходящие пакеты разных типов. Но сетевые работы производятся только в том случае, если у главперсонажа включен мультиплеерный режим (читай выше).

Поскольку пакеты с данными о появлении нового, а также с данными об уходе старого клиента передаются с сервера по tcp, значит, его мы и должны прослушивать. Функция mnRecvTCP проверяет входящий tcp-поток. У нее есть лишь один параметр, который для клиентской стороны не имеет значения, поэтому равен NULL. Если входящих пакетов не обнаружилось, то функция возвращает 0, а если один или несколько пакетов есть, тогда она возвращает число больше 0 (= числу пакетов, стоящих в очередь на обработку). Рассмотрим сценарий, по которому пакет пришел. Из него извлекаются два значения: номера операции и клиента. Затем проверяется, какая это операция: tcp-операций всего две (смотри начало абзаца). Если операция – «Новый клиент», то в цикле ищется неактивный робот. В случае нахождения он становится активным и привязывается под управление от вновь подключившегося клиента. Если же операция – «Клиент ушел», то в цикле снова производится поиск по всем вражеским роботам с целью обнаружить робота с номером (cnumber, возвращаемым функцией GetNumber), равным номеру клиента, отправившего сообщение об уходе. После того, как номера совпадут, найденный вражеский робот уничтожается.

После проверки tcp проверяется udp. Операций UDP тоже два типа: первый – это манипуляция объектом (роботом) (PositionClient), а второй – сообщение, представляющее собой сигнал о победе (Win). С обработкой пакета первого типа все более или менее ясно: этот фрагмент кода похож на код, рассмотренный в серверном исходнике, когда данные принимались от одного определенного клиента и отправлялись остальным. Только здесь вместо формирования и отправки пакета принятые данные участвуют в манипуляции над объектом: перемещение и поворот с помощью функций Dark GDK. Тут же проверяется, выстрелил ли клиент. На этом выполнение операции и первый условный оператор, относящийся к операции PositionClient, завершаются, и далее идет второе условие, которое проверяет отношение операции ко второму типу - Win. Если сервер действительно отправил пакет udp с такой операцией, значит, кто-то набрал нужное количество фрагов. Игра завершена, и надо вывести таблицу результатов. Конечно же, мы реализовали в игре такую возможность! Но прежде, после того, как стало ясно, что пришел win-пакет, мы должны извлечь из него информацию, поскольку в этой посылке содержатся имена всех клиентов вместе с численным выражением их ратных подвигов. Для этого в цикле мы заполняем массив переменных структурного типа, объявленного ранее данными из пакета. Теперь вся итоговая таблица сохранена в массиве. Далее удалим из памяти динамические объекты, скроем статические и вызовем самописную функцию NetGameOver, которая, собственно, обработает массив с данными и выведет их на экран в виде двух столбцов.

По завершении условных операторов следует код формирования и отправки udp-пакета. С этим мы уже знакомы (для освежения памяти смотри раздел «Сервер»).

Избегаем неприятностей

У нас остались два нерассмотренных вопроса:

  • Зачем, все-таки, отправлять и изменять (вне геймплея) количество жизней?
  • Почему после того, как отправляется пакет с лимитом фрагов, переменная-член обнуляется?

На второй вопрос ответ прост: чтобы сигнал о победе был отправлен лишь однажды. А количество жизней отправляется, чтобы синхронизировать клиентов. Объясню подробнее. Например, в битве участвуют три робота. У каждого разное количество жизней. Один погибает, а когда возвращается в игру, его клиентская часть создает динамические объекты заново, добавляя врагов и раздавая им по максимуму жизней (хотя в виртуале у них может быть меньше). Поэтому, когда он будет стрелять по противникам, они уже помрут, а на его машине - останутся живы, так как жизней-то у них больше, чем в реальности. Во избежание этой неприятной ситуации клиенты сообщают своим оппонентам, сколько у них жизней в реале, и происходит синхронизация между клиентами. В синхронизации, по большому счету, и заключается основная трудность разработки онлайновых игр.

Кстати, наконец-то, в игре появилась пауза! Теперь, при нажатии Escape во время игры, она не завершит свою работу, как можно было ожидать. Напротив, перед юзером откроется (снова) консольное меню, в котором будет написано, какие клавиши для чего.

Последнее, на что надо обратить внимание, это изменения в файле Game_Obj.h. В нем только изменен прототип функции Die. Если раньше она была чисто виртуальной, поскольку во всех классах-потомках использовалась без изменения прототипа, то сейчас в классе роботов в деструктор добавился параметр, и поэтому она перестала быть чисто виртуальной.

Мы рассмотрели новый, связанный с мультиплеером код, погрузились во все хитросплетения сетевых игр и разработали многопользовательскую баталию на основе игрового движка DarkRobot. Если ты прочитал все статьи цикла, то уже знаешь толк в игростроении, искусно владеешь техникой создания компьютерных игр и, вообще, являешься светлым джедаем, которому предстоит длинный путь к вершине мастерства! Как ты воспользуешься этими знаниями, зависит только от тебя. Вариантов масса: возможно, ты будешь программистом в какой-нибудь крутой фирме и своим талантом поможешь российской игровой индустрии, может быть, махнешь за океан, чтобы помочь местным: а может быть, откроешь свою студию, где будешь проводить исследования в области электронных развлечений, и разработаешь абсолютно новый игровой жанр!

Заключение

В заключение хочу отметить положительные и отрицательные стороны использования интерфейсного ПО (middleware), к которому, в частности, относится Dark GDK. Конечно, автор знаком с заверениями типа: «Зачем мне нужна какая-то надстройка, есть же DirectX!». Да, yurembo и сам в детстве так говорил, но тогда у него не было нормального графического акселератора, и он все игры писал под DirectDraw, огрызаясь направо и налево. Сейчас же, когда крутые 3D-ускорители есть практически у каждого, подобный консерватизм будет выглядеть очень странно. Поэтому, воспользовавшись интерфейсным ПО, которое порой абсолютно бесплатно (как в рассматриваемом случае), можно сократить время на разработку игрового (в частности, графического) движка, и посвятить его вещам творческим: обдумыванию сюжета и проработке геймплея!

Если тебе захочется еще что-нибудь узнать о разработке игр (в частности, о кодировании движков), то пиши, yurembo всегда открыт для общения. Удачи тебе во всех начинаниях, игродел!

От редакции

Предыдущие статьи цикла вышли в номерах ][ за октябрь, ноябрь и декабрь прошлого года («Темное искусство игродела», «Игра в одни ворота», «Темное искусство игродела, часть 3»). На этом мы хотели было завершить цикл статей «Темное искусство игродела», ошибочно посчитав его несколько унылым, но читатели с нами не согласились. Они проспамили мыло редактора рубрики требованиями обещанного завершения банкета – статьи про мультиплеер. Мы были вынуждены запросить пощады. Приносим свои извинения – вот она, эта статья.

DVD

На диске лежит полный исходный код финальной версии многопользовательской игры DarkRobot, для компиляции которого нужны: Visual C++ 2008 Express Edition, DirectX 9.0 SDK, Dark GDK, MikeNet.

WWW

INFO

Если тема тебя заинтересовала, сообщи об этом автору, продолжим развитие хакерского игропрома.

Содержание