Примерно год назад вышла моя статья, которую можно назвать "первой частью" данной статьи. В первой части я насколько смог подробно разобрал тернистый путь разработчика-энтузиаста, который мне удалось когда-то самостоятельно пройти от начала и до конца. Результатом этих усилий стала игра жанра RTS "Земля онимодов" созданная мною в домашних условиях без движков, конструкторов и прочих современных средств разработки. Для проекта использовались C++ и Ассемблер, ну, и в качестве основного инструмента моя собственная голова.
В этой статье я постараюсь рассказать о том, как я решил взять на себя роль «реаниматора» и попытаться «воскресить» этот проект. Много внимания будет уделено написанию собственного игрового сервера.
Это окончание статьи, начало тут.
Начало статьи: Воскрешение игры
Продолжение статьи: GUI
Сеть
Я не думаю, что без анализа прилагаемого примера можно будет в тонкостях разобраться в том, о чем я буду пытаться говорить. Однако общие мысли на этот счёт, я надеюсь, мне удастся донести в любом случае. Также я должен отдельно отметить, что подобные решения я придумываю самостоятельно, поэтому всегда есть риск, что какое-то моё решение проблемы окажется неоптимальным. Библиотеку для сети libuv я изучал в основном методом «научного тыка», и я также вполне допускаю, что люди, которые занимаются сетевым программированием постоянно, смогут поправить какие-то мои трактовки. А теперь, пожалуй, я вернусь к теме статьи…
Сначала я честно хотел использовать для сетевого взаимодействия библиотеку WinSock. Однако быстро передумал, так как было очевидно, что таким образом я опять окажусь полностью привязанным к Windows, чего мне очень не хотелось. Поэтому я порылся в интернете и обнаружил интересное решение под названием libuv. Данная библиотека является бесплатной. Работает на Windows, Unix, Mac OS и Android. А также не тянет за собой современные новшества, вроде требования использовать последний стандарт языка C++. Да и вообще она написана на чистом C, что я считаю дополнительным благом для разработчика, так как мой вечноспасительный принцип звучит как: «чем проще — тем лучше».
По началу у меня было 2 проблемы:
- Я никогда не писал серверов с нуля, поэтому мне нужно было придумать общую структуру программы.
- Документация на libuv, на мой взгляд, оставляет желать лучшего. Меня вообще всегда удивляло, как можно сделать вполне приличный продукт и полениться более-менее описать его возможности. Но, к сожалению, этим страдают почти все разработчики. Последняя стадия лени в этой области — это использование автоматической генерации документации с помощью утилиты Doxygen (и ей подобных), которая превращает в справку комментарии в коде, а также автоматически генерирует связи между классами и структурами. Я, вероятно, отстал от жизни, но я не вижу лучшего способа навредить собственной библиотеке, чем автоматически сгенерировать для неё «помойку» из всевозможных структур и диаграмм, где часто непонятно с чего начать.
Вторая проблема у меня была частично компенсирована тем, что WinSock я тоже никогда не использовал и, соответственно, мне было не так важно, с чем разбираться с WinSock или с libuv. Но так как libuv обещал мне несколько платформ без необходимости что-то переписывать, то в моих глазах он однозначно победил.
Несмотря на то, что сейчас разработчики игр часто убирают возможность игры по локальной сети, я решил, что сохраню и этот вариант сетевой игры тоже. В первой части статьи я собственноручно объяснял, что для игры в RTS по локальной сети никакой сервер вообще не нужен, так как игра работает на каждом компьютере независимо и никакого координирующего центра не требуется. И это действительно так. Однако в моем случае я хотел получить два варианта сетевой игры: через интернет и через локальную сеть. Если бы я стал использовать для локальной сети одноранговый способ взаимодействия компьютеров, то такое решение создало бы вторую ветку кода. Работало бы такое решение быстрее? Вероятно да, но навряд-ли игрок смог бы ощутить разницу, так как в локалке данные обычно быстро доходят до цели.
В результате я логично рассудил, что у меня и для локальной сети будет тоже использоваться сервер.
Немного теории про адреса и порты
Как, наверное, многие знают, каждому компьютеру в мире, включенному в Интернет, должен быть присвоен уникальный адрес (если, конечно, не используется NAT или что-то подобное). Этот адрес называется IP. IP-адрес состоит из 4-х цифр и в «человеческом виде» выглядит примерно так: 234.123.34.18
По таким адресам компьютеры и находят друг друга. Однако обычно на одном компьютере запущено одновременно множество программ, поэтому существует дополнительное понятие «порт». Программы могут открывать для себя эти порты и устанавливать через них взаимодействие. Чтобы было более понятно… IP-адрес — это как бы: Россия, Бухаловская обл., село «Большое голодухино», ул. «Новый русский спуск», д.18, а «порт» — это номер квартиры, где деньги лежат в которой проживает конкретный человек. Без номера квартиры(порта) невозможно доставить «письмо» конкретному человеку (программе), поэтому понятие «порт» имеет очень важное значение. Обычно порт записывается после IP-адреса через двоеточие, например: 234.123.34.18:57
Доставкой сообщений между компьютерами занимаются специальные программы, которые называются протоколами. Самый известный протокол, на котором держится весь интернет именуется TCP. Есть и еще один очень важный протокол, который называется UDP, но пользоваться им несколько сложнее.
Кратко поясню разницу для тех, кто не очень знаком с этой темой.
UDP позволяет передавать данные порциями. Такая порция данных называется датаграмма. Датаграмма может быть отправлена на указанный IP-адрес и порт. Но… протокол UDP «ничего вам не обещает», т.е. отправленная датаграмма может запросто потеряться по дороге и в этом случае получатель просто ничего не получит. А если было отправлено несколько датаграмм, то они могут прийти в другом порядке или некоторые могу не прийти, т.е. тут возможны любые варианты и это является совершенно законным поведением для UDP. Единственное, что гарантирует UDP, так это тот факт, что если датаграмма всё-таки пришла, то она пришла полностью, т.е. не может прийти «половина» датаграммы.
TCP работает совсем по иному принципу. Он не отправляет какие-то непонятные датаграммы в неизведанное сетевое пространство на произвол судьбы — он сначала устанавливает канал связи с получателем и четко шлёт данные именно по этому каналу. TCP гарантирует, что все переданные данные дойдут до получателя, и дойдут именно в том порядке, в котором они были отправлены. Сами же данные приходят не в виде датаграмм фиксированной длины, а в виде потока байт (хорошей аналогией служит побайтная запись данных в файл). Обратите внимание, что при таком подходе TCP может делить отправляемые сообщения на части как ему вздумается, т.е. получатель может сначала получить лишь часть отправленных данных, а через некоторое время подоспеет и оставшаяся половина.
Для связи между программой и портом используются, так называемые, сокеты. Сокет подключается к порту и далее всё взаимодействие с портом происходит через этот сокет. Существования сокета без порта не имеет особого смысла, так как ничем другим кроме взаимодействия с портом, сокет не занимается.
Выбор протокола
На начальной стадии мне потребовалось выбрать протокол для взаимодействия. Если не писать собственное решение, то тут имеется всего два варианта: TCP и UDP. UDP хорош там, где нельзя допускать задержек в игровом процессе. Обычно сама игра при этом выполняется на сервере, а клиенты лишь получают от сервера данные об изменениях в игровой ситуации. Такой подход позволяет буквально «не обращать внимания» на игрока, с которым плохая связь. Все остальные игроки будут продолжать играть вполне комфортно, так как сервер не останавливает игру, чтобы дождаться отстающего. Пример такой игры — это Counter Strike.
В случае же с RTS такой подход чаще всего не годится, так как игровой процесс требует большого количества вычислений и поэтому выполняется на каждом компьютере, и эти компьютеры должны всё делать одинаково. Поэтому каждый компьютер от каждого другого компьютера должен постоянно получать список действий, проделанных игроком за один «сетевой такт». Если список запаздывает, то придется подождать, пока он будет получен. Т.е. даже если использовать UDP, то контроль за доставкой сообщений придется выполнять самостоятельно. Поэтому был выбран TCP.
Как я слышал, Starcraft2, несмотря на то, что он относится к жанру RTS, работает-таки на сервере. Возможно, что современное железо дошло до такого уровня, чтобы использовать этот способ. Но в моем случае сеть сделана «классическим» для RTS способом.
Что же делает сервер?
На самом деле он не делает почти ничего, пока не получит какое-либо сообщение от клиента. В случае с TCP задача сервера — открыть порт и слушать его. Если на этот порт приходит запрос от клиента с просьбой об установке соединения CONNECT, то сервер должен выполнить ACCEPT, который открывает новый случайный порт и сообщает его клиенту. Клиент, получив случайный порт от сервера, открывает собственный случайный порт и по этим портам между сервером и клиентом устанавливается соединение. Соединение будет существовать до тех пор, пока одна из сторон не пожелает его закрыть, либо пока не произойдет обрыв связи по техническим причинам. Сервер устанавливает по одному соединению с каждым клиентом. Все данные, которые клиенты пересылают друг другу, проходят через сервер, но никогда не напрямую от клиента к клиенту, как в случае с одноранговой сетью.
Мой сервер занимается следующими задачами:
- Обменивается «приветствием» с клиентом и, если всё в порядке, то предоставляет клиенту возможность обмениваться сообщениями с собой и другими клиентами.
- Перебрасывает сообщения между клиентами, выступая в роли посредника.
- Регистрирует новый игровой сеанс и рассылает информацию о нем тем клиентам, которые запрашивают список существующих сеансов (игровой сеанс создается хостом, т.е. клиентом, который создает сетевую игру и ожидает других игроков для присоединения к ней).
- Удаляет игровой сеанс, если в нем больше нет игроков. Сервер следит за тем, какие игроки входят в какой сеанс.
- Занимается пересылкой текстовых сообщений между игроками, т.е. например, ориентируясь на имя игрока, может передать приватное сообщение только данному игроку. Практически, это обычный чат.
- Отслеживает ситуацию с экстренным отсоединением игрока — сообщает об этом факте другим участникам игрового сеанса.
- Следит за сетевой активностью каждого игрока. Если от игрока долго не поступает никаких данных, то сервер полагает, что здесь что-то не так и сбрасывает соединение.
- Отвечает на ping-запросы от клиента. Пингование сервера позволяет приблизительно определить задержки в соединении, а также поддерживать активность клиента, за которой следит сервер.
- Позволяет осуществлять администраторский контроль над своей работой. Вообще, администрирование — это отдельная история, которая не имеет ничего общего с игрой. В моем случае, я написал для себя отдельную утилиту, которая будет контролировать Интернет-сервер моей игры. Ничем другим эта утилита не занимается, как только показывает статистику сервера: количество игроков, количество игровых сеансов, связь между игроками и сеансами и прочее. Также она позволяет перезапускать и выключать сервер.
Что же делает клиент?
На клиенте происходит сама игра. Клиент знает, что в игре он участвует не один и необходимо отправлять другим участникам сообщения о действиях игрока и принимать такие же сообщения.
- Обменивается «приветствием» с сервером и в случае успеха получает возможность обмениваться сообщениями по сети.
- Создает собственный игровой сеанс (в этом случае такой клиент называется хост, так как он является «старшим по сеансу»).
- Запрашивает у сервера список уже существующих игровых сеансов.
- Имеет возможность присоединиться к уже существующему игровому сеансу.
- Будучи участником сеанса, может изменять свои характеристики, например, выбирать политический союз и расу. Кроме этого хост может добавлять в сеанс ботов или удалять из сеанса других игроков, а также отменить сеанс.
- Обмен текстовыми сообщениями между присутствующими на сервере, говоря не по-русски, «чат».
- Если кто-то из участников сеанса не имеет той карты, на которой будет происходить игра, то хост должен переслать ему эту карту.
- Хост имеет право запускать игру, если все игроки отметились флагом «Готов».
- Во время игры клиенты накапливают действия игрока от мыши и клавиатуры за определенный период времени (сетевой такт) и отсылают их другим игрокам. Невозможно продолжать игру, если не был осуществлен полный обмен этими сообщениями между всеми участниками игрового сеанса. Если какой-то игрок запаздывает, то остальные игроки будут его ждать. Если ожидание затягивается, то выводится сообщение на тему «Проблема связи с таким-то игроком» и счетчик времени, который отсчитывает время в обратную сторону до 0. Если за это время связь с игроком не восстанавливается, то тот принудительно отсоединяется от игры.
- В случае, если замечена «рассинхронизация сети» (когда на разных компьютерах игра начинает протекать по-разному), то хост пытается произвести восстановление испорченной игры. Для этого, практически, делается запись всех данных игры в файл и отправка этого файла остальным участникам сеанса. В свою очередь другие участники читают этот файл и полностью заменяют свои данные на данные, полученные от хоста. По сути, это аналог Save/Load.
Различия между интернет-сервером и сервером локальной сети
Как это не парадоксально звучит, но сервер локальной сети устроен несколько сложнее, чем Интернет-сервер. Почему так?
Обратите внимание, что Интернет-сервер работает в качестве отдельной программы, которая один раз где-то там запущена и должна в идеале работать вечно. Локальный же сервер создается на время на компьютере, который выполняет роль хоста. Такой сервер существует не в виде отдельной программы, а лишь в виде отдельного потока, который имеет доступ к любым данным основного приложения, что влечет за собой мелкопакостные проблемы, которые лечатся синхронизацией потоков. А «многопоточность» — это вообще отдельный раздел отладки, который может сильно и надолго подпортить жизнь любому разработчику.
Далее, заметьте, что принцип подключения клиента к Интернет-серверу и локальному серверу абсолютно разный. Чтобы клиент мог подключиться к Интернет-серверу он должен знать его IP-адрес и порт. И эти данные должен указывать игрок в каком-нибудь текстовом поле. А в локальной сети существующие игровые сеансы должны обнаруживаться без указания каких-либо IP-адресов. Достигается такое действие через так называемый «широковещательный запрос», у которого IP равен 255.255.255.255. Этот адрес обозначает «всю локальную сеть сразу», но для протокола TCP это не сработает, так как это фича UDP в чистом виде. Почему TCP не сможет работать с широковещательным адресом? Ну, как я выше объяснял, TCP обязательно должен установить канал связи и общаться строго один на один. А здесь же нужно «окликнуть» всю локальную сеть по принципу «эй, есть тут кто? отзовитесь!». Ну и те, кто «есть», должны отозваться, передав просителю информацию об игровом сеансе. Поэтому на локальном сервере придется использовать еще и UDP.
Ну, и на сладкое… представьте, что во время игры хост, на котором работает локальный сервер, вдруг проиграл или просто вышел из игры по каким-то причинам. Что у вас произойдет? А случится то же самое, что происходит в игре Diablo2, когда тот, кто создал игру, решил покинуть игровой сеанс раньше остальных. В Diablo2 у остальных несчастных на черном экране вылезает сообщение в стиле «хост больше недоступен», и, собственно, всё… игроки просто выбрасываются из игры. Причина такого поведения в том, что уходя из сеанса хост закрывает и свой локальный сервер, а ведь все игроки присоединены именно к нему. Для борьбы с таким безобразным поведением в DirectPlay когда-то существовала прекрасная штука, которая называется «миграция хоста». В двух словах это выглядит так… когда от локального сервера приходит информация о его внезапной кончине, оставшиеся клиенты принимают решение запустить новый сервер и переприсоединиться к нему заново. Запуск локального сервера можно делать на клиенте, который самый первый в списке игроков сеанса. Далее сервер должен подождать пока все, кто был ранее в игре, смогут к нему подключиться, ну, и, если всё в порядке, то игра продолжится.
У Интернет-сервера, правда, тоже существуют свои особенности. Главной особенностью является то, что всегда есть опасность, что сервер вылетит или зависнет по причине ошибки разработчика или ОС. В этом случае все игроки, которые сейчас находятся в игре, будут очень раздосадованы. Но с этой ситуацией вряд ли можно что-то сделать, разве что постараться выявить как можно больше ошибок до релиза. Но, тем не менее, в любой сложной программе ошибки остаются, и вопрос здесь лишь в вероятности их возникновения.
Но даже допустим, что сервер вдруг взял и «умер», а игроки были отсоединены. После того, как разработчика перестают проклинать за его «кривые руки», игрок обычно пытается снова подключиться к серверу. Но… если сервер «умер», то он не заработает до тех пор, пока его кто-то не перезапустит, а если за работой сервера никто не следит, то это может произойти очень нескоро. В результате гнев игрока может начать стихийно нарастать, что может привести к серьёзным психологическим последствиям. Он начнет плохо спасть, анализы резко ухудшатся и, практически, человек окажется близок к нервному расстройству. Лично я не хотел бы брать на себя такую ответственность, поэтому решил заранее подстраховаться.
Интернет-сервер не должен работать самостоятельно — должен быть контролёр, который будет запускать сервер и стараться следить за его работой. Для этого требуется периодически посылать на сервер сигнал и получать ответ. Если сигнал остался без ответа, то это означает, что сервер «терпит крушение». В этом случае нужно сначала убить процесс сервера окончательно, а потом запустить сервер заново. Такой подход должен хоть как-то защищать сервер, хотя не знаю, насколько это будет эффективно на практике, но я не сомневаюсь, что это ход в правильном направлении.
Кроме того, программа-контролер может выполнять некоторые дополнительные действия. Например, можно поручить ей обновление сервера. В моем случае это выглядит так:
1) Например, я решил что-то поправить на сервере и собрал новую версию сервера, но если игрою кто-то интересуется, то на сервере постоянно кто-то играет. Перезапуск сервера приведет к тому, что игроки будут выброшены из игры, что всегда чревато душевными переживаниями.
2) Зная об этом, я учу сервер и контролирующую утилиту обновлять сервер в «мягком режиме», который выполняется следующим образом. Через утилиту администрирования новый сервер передается в виде обычного файла, который попадает на компьютер, где выполняется основной сервер. После этого основной сервер закрывает свой слушающий порт, но не завершает работу, т.е., практически, новые игроки больше присоединиться не смогут, а те кто «уже тут», будут продолжать спокойно играть. Далее этот сервер будет периодически проверять на ситуацию, когда от него отсоединяться все игроки, и тогда он спокойно завершит работу. В это время программа-контроллер обнаружит присланный файл нового сервера и запустит его на выполнение параллельно со старым сервером. Так как слушающий порт от старого сервера будет уже освобожден, то его теперь начнет слушать новый сервер, который и будет подключать на себя всех новых игроков. Через некоторое время старый сервер сам себя выключит, и в работе останется только одна новая версия сервера.
Всё это достаточно неплохо звучит, но, к сожалению, на момент написания статьи я так и не смог проверить сервер под хорошей реальной нагрузкой. Это есть типичная проблема разработки проектов на энтузиазме, когда нет команды тестеров, которым платят за то, что они просто играют и сообщают о найденных неполадках. Но здравый смысл подсказывает мне, что я всё делаю в правильном направлении, поэтому свои мысли на этот счёт я включил в статью.
Функции библиотеки libuv
В этом разделе я постараюсь кратко описать функции библиотеки libuv, с которыми мне пришлось иметь дело во время разработки сетевого взаимодействия. Как минимум, эта информация может оказаться полезным справочником, в том числе и для меня, так как хорошая память не является моим сильным качеством.
Взаимодействие libuv с пользователем целиком построено на использовании функций обратного вызова или callback-функциях. Как это работает? Например, пользователь хочет отправить сообщение по TCP. Для этого используется функция uv_write(), которая, естественно, принимает в качестве параметров «что отправлять» и «через какой сокет». Но кроме этого нужно еще указать и адрес пользовательской функции, которая будет вызвана, когда отправка успешно завершится. Именно эти функции и позволяют контролировать происходящие события. То же самое касается и приема сообщений, только для этого применяется функция uv_read_start(), которой тоже указывается пользовательская функция, которая будет вызвана после приема очередной порции данных.
Самой важной функцией является функция uv_run(), которая, по сути, представляет из себя что-то вроде цикла обработки сообщений в Windows. Все вызовы callback-функций библиотека производит только внутри uv_run(). Это означает, что даже если сама libuv и приняла какое-то сообщение по сети, то пользователь о нем никогда не узнает пока не будет вызвана uv_run().
Функция uv_run() объявлена следующим образом:
int uv_run(uv_loop_t* loop, uv_run_mode mode);
Первый параметр uv_loop_t* loop является указателем на структуру, которую libuv использует для каких-то своих личных надобностей. Эту переменную нужно создать один раз и больше никогда не трогать. Создать её можно, например, так:
uv_loop_t loop;
memset(&loop, 0, sizeof(loop));
uv_loop_init(&loop);
Это всё, что касается параметра loop и нет особой необходимости знать, как он используется внутри libuv. Но… есть один важнейший нюанс. Если у вас многопоточная программа, то для каждого потока нужно использовать собственный loop. В моем случае я создаю один loop для самой игры и второй для локального сервера, который выполняется в другом потоке. И соответственно, у меня на каждом потоке вызывается собственный uv_run().
Второй параметр uv_run_mode mode определяет режим, в котором будет работать функция uv_run(). Для сервера нужно использовать значение UV_RUN_DEFAULT, а для клиента — UV_RUN_NOWAIT. Попробуем разобраться почему именно так.
Параметр UV_RUN_DEFAULT заставляет функцию uv_run() выполняться до тех пор, пока для неё есть хоть какая-то работа. А такой работой, например, является задача по прослушиванию порта. Т.е. если сначала был создан сокет, который слушает порт, то uv_run() никогда не завершится, пока этот сокет будет существовать. А это и есть основная задача сервера — ожидать соединение от клиента и устанавливать его. Поэтому вариант с UV_RUN_DEFAULT является для сервера очень правильным, а строка:
uv_run(&loop, UV_RUN_DEFAULT)
часто бывает последней строчкой в программе, так как при выходе из этого цикла сервер просто завершает работу.
Выход из функции uv_run(&loop, UV_RUN_DEFAULT) произойдет самостоятельно, когда пользователь уничтожит слушающий сокет.
Для аварийного выхода из функции uv_run(&loop, UV_RUN_DEFAULT) предназначена функция uv_stop(), которая принимает в качестве параметра тот же самый loop. После такого вызова uv_run() завершит работу, но вернет ошибку, которая будет означать, что её прервали слишком рано и ей еще есть чем заняться. Никто, кстати, не мешает в этом случае вызвать uv_run() снова.
Параметр UV_RUN_NOWAIT заставляет функцию uv_run() разобраться только с теми событиями, которые произошли к настоящему моменту. Т.е., если были получены какие-то сетевые сообщения, то для них будут вызваны callback-функции. После этого функция uv_run() будет завершена. Такое поведение как раз хорошо подходит для клиента, так как клиент помимо обмена сетевыми сообщениями, занимается еще и самой игрой. В моем случае я вызываю uv_run(&loop, UV_RUN_NOWAIT) один раз в начале игрового такта и один раз в конце (частота тактов примерно 60 Гц). Это сделано, чтобы была возможность обработать перед началом такта полученные сообщения, а после окончания такта сразу отправить собственные.
Как было сказано выше, протокол TCP требует обязательной установки соединения. Запрос на соединение всегда отправляет клиент на конкретный IP-адрес и порт сервера. Для этого испольуется функция uv_tcp_connect().
Функция uv_tcp_connect() объявлена следующим образом:
int uv_tcp_connect(uv_connect_t* req, uv_tcp_t* handle, const sockaddr* addr, uv_connect_cb cb);
Первый параметр uv_connect_t* req — это указатель на какую-то структуру, которая, по-видимому, для чего-то очень нужна libuv. Задача пользователя просто создать эту структуру и передать её в функцию. Создание структуры выполняется более чем просто:
uv_connect_t connect_data;
Я на всякий случай пишу в неё нули, но, кажется, это не обязательно:
memset(&connect_data, 0, sizeof(connect_data));
Также обратите внимание, что эта переменная должна продолжать существовать и после вызова uv_tcp_connect(), так как её адрес используется в callback-функции, поэтому проще сделать её глобальной.
Второй параметр uv_tcp_t* handle — это TCP-сокет, который должен быть создан заранее, но не привязан к каком-либо порту. Создание TCP-сокета выполняется функцией uv_tcp_init(), которая будет рассмотрена немного позже.
Третий параметр const sockaddr* addr — это IP-адрес и порт сервера, у которого запрашивается соединение. У libuv есть функция uv_ip4_addr(), которая помогает заполнить эту структуру данными.
sockaddr_in dest;
uv_ip4_addr("234.123.34.18", 57, &dest);
Четвертый параметр uv_connect_cb cb — это пользовательская функция обратного вызова. И именно в этой функции пользователь сможет определить было ли установлено соединение и как-то отреагировать на этот факт.
В моем случае функция обратного вызова выглядит примерно так:
void OnConnect(uv_connect_t* req, int status)
{
if (status==0)
{
// соединение установлено
...
...
...
}
else
{
// соединение не состоялось
...
...
...
}
}
Создание сокетов.
TCP-сокет описывается структурой uv_tcp_t. Для начала нужно выделить память под эту структуру:
uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t));
Желающие могут очистить выделенную память ноликами, хотя это необязательная операция:
memset(tcp_socket, 0, sizeof(uv_tcp_t));
Далее можно создать сам сокет:
uv_tcp_init(&loop, tcp_socket);
здесь loop — это тот же самый многострадальный loop, который подается в uv_run().
А вот теперь очень важный момент для писателей игр:
uv_tcp_nodelay(tcp_socket, true);
Попробую пояснить, что это за настройка. Дело в том, что протокол TCP считает себя очень умным протоколом и он знает, что отправлять данные малыми порциями невыгодно, так как минимальный передаваемый размер данных в любом случае будет равен размеру пакета. Иными словами, если вы пошлете 1 байт, то в результате будет всё равно отправлена целый пакет, в которой из полезных данных будет только 1 байт, а остальное будет мусором. Поэтому по умолчанию умный TCP будет ждать 200 миллисекунд, чтобы пользователь добавил еще данных на отправку, чтобы передать всё сразу. Этот механизм с ожиданием называется "Алгоритм Нейгла" и для игр он совсем не подходит. Поэтому данная настройка как бы говорит протоколу TCP — «слушай, уважаемый, давай не умничай, а отправляй данные сразу». Практически, эта настройка запрещает протоколу TCP использовать "Алгоритм Нейгла".
Теперь, если это сервер, то основной сокет необходимо сразу привязать к порту:
sockaddr_in address;
uv_ip4_addr("0.0.0.0", НОМЕР ПОРТА СЕРВЕРА, &address);
uv_tcp_bind(tcp_socket, (const struct sockaddr*)&address, 0);
В данном случае в качестве адреса указывается «0.0.0.0», что означает, что сокет будет привязан ко всем сетевым адаптерам, которые присутствуют на компьютере, а не только к какому-то одному. НОМЕР ПОРТА СЕРВЕРА должен быть выбран самостоятельно и тут главным является его уникальность, чтобы не создавать конфликта с другими программами.
Далее сервер должен включить для сокета прослушку порта:
uv_listen((uv_stream_t*)tcp_socket, 1024, OnAccept);
Первый параметр tcp_socket — это, понятное дело, сам сокет, а вот второй куда более специфичен. Это максимальное количество запросов на соединение, которые могут ожидать своей очереди. Представьте, что у вас супер-популярный сервер и игроки наперебой рвутся на нем поиграть, т.е. запросов на соединение от клиентов приходит очень много. Если сервер не успевает на них отвечать, то он ставит их в очередь. И вот это число 1024 в данном случае и есть максимальный размер этой очереди. Тем, кто не помещается в очередь, сервер будет отвечать тремя словами.
Третий параметр OnAccept — это callback-функция, которая будет вызвана, когда от какого-нибудь клиента придет запрос на соединение CONNECT, для чего клиент использует функцию uv_tcp_connect().
Функция OnAccept() может выглядеть примерно так:
void OnAccept(uv_stream_t *server, int status)
{
if (status<0)
{
// Ошибка установки соединения
return;
}
// Сервер создает новый сокет, чтобы через него установить соединение с клиентом
uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t));
memset(tcp_socket, 0, sizeof(uv_tcp_t));
uv_tcp_init(&loop, tcp_socket);
if (uv_accept(server, (uv_stream_t*)tcp_socket)==0) // делается попытка установить соединение
{
// Соединение установлено
uv_read_start((uv_stream_t*)tcp_socket, OnAllocBuffer, OnReadTCP); // Переводим сокет в режим чтения
}
else
{
// Соединение установить не удалось
uv_close((uv_handle_t*) tcp_socket, OnCloseSocket); // Удаляем сокет, который был создан для соединения
}
}
Чуть подробнее разберем внутренности функции OnAccept().
- Сначала создается новый сокет, который будет использован для соединения с клиентом, от которого пришел запрос на установку соединения. Для установки соединения клиент использует uv_tcp_connect().
- Вызывается uv_accept(), которая выполняет установку соединения между сер-вером и клиентом. Функция uv_accept() вызывает на клиенте срабатывание call-back-функции, указанной в uv_tcp_connect() в качестве последнего параметра. В примере выше это была функция OnConnect().
- Если соединение успешно установлено, то сервер должен позволить созданному сокету читать данные из этого соединения. Функция uv_read_start() включает чтение данных для только что созданного сокета tcp_socket. Обратите внимание, что «чтение данных» и «прослушивание сокета» — это разные операции. «Чтение данных» — это в прямом смысле «чтение» по аналогии с побайтным чтением из файла, а «прослушивание сокета» — это ожидание запроса CONNECT для установки соединения.
Функция uv_read_start() использует целых две callback-функции:
— OnAllocBuffer() вызывается перед чтением данных и просит пользователя указать память для приема данных.
Сама функция определена так:
void OnAllocBuffer(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf)
Первый параметр uv_handle_t* handle — это указатель на сокет, которому требуется память.
Второй параметр size_t suggested_size — это требуемый размер буфера.
Третий параметр uv_buf_t* buf — это структура, через которые пользователь возращает библиотеке libuv информацию о буфере (размер и адрес).
buf->len=РАЗМЕР; buf->base=АДРЕС;
Короче говоря, сам буфер должен создаваться пользователем и удаляться тоже пользователем. А libuv будет только принимать в этот буфер данные. Один и тот же буфер может быть использован для множества сокетов.
В моем случае, libuv почему-то всегда запрашивала буфер размером 65536 байт. Как по мне, так это немного странно, но так как я выделяю эту память 1 раз, то вроде бы ничего страшного в этом нет.
— OnReadTCP() вызывается после OnAllocBuffer(), чтобы передать пользователю данные, которые сокет принял в буфер.
Сама функция определена так:
void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf)
Первый параметр uv_handle_t* handle — это указатель на сокет, который принял данные.
Второй параметр ssize_t nread — это количество принятых байтов. Если nread меньше или равен нулю, то это признак того, что соединение было разорвано. Т.е. это не чтение данных, а информация о обрыве связи, который может произойти из-за того, что вторая сторона удалила сокет, относящийся к данному соединению или по аппаратным причинам типа «оборыв кабеля». Данную ситуацию обязательно необходимо отслеживать и реагировать на неё соответствующим образом.
Третий параметр const uv_buf_t* buf — содержит адрес буфера с прочитанными данными. Это будет тот же самый адрес, который пользователь указал в функции OnAllocBuffer().
Важнейший момент при чтении данных. Как я уже писал ранее, протокол TCP может доставлять отправленное сообщение по частям, например, если отправитель послал фразу «Привет,», а затем фразу «Сервер», то совсем необязательно, что при получении этих двух сообщений информация будет выглядеть точно также, как и в отправляемом виде. Во-первых, оба сообщения могут слипнуться в одно и тогда принимающая сторона получит одно цельное сообщение «Привет, Сервер», или может быть даже так: сначала «При», потом «вет, Сер», а потом «вер». Т.е. сообщение в теории может быть раздроблено на сколько угодно частей. Поэтому у всякого сообщения передаваемого по сети всегда должен быть заголовок, который позволит отделять в потоке приходимых на сокет байт одно сообщение от другого. Из всего этого следует очень неприятная особенность чтения данных из TCP-сокета. Практически, каждый такой сокет должен иметь собственный буфер, куда он будет складывать приходящие байты очередного сообщения, ведь если сообщение получено лишь частично, то требуется подождать, когда оно будет принято полностью и только потом уже можно на него как-то реагировать.
- Если соединение установить не удалось, тогда предназначенный для него сокет нужно удалить. Для этого используется функция uv_close(). Но… обратите вни-мание, что эта функция также принимает в качестве последнего параметра call-back-функцию OnCloseSocket(). И именно в момент вызова этой функции libuv сообщает пользователю, что теперь сокет можно удалить из памяти физически.
void OnCloseSocket(uv_handle_t* handle) { free(handle); // сокет был создан через malloc(), теперь его нужно удалить через free() }
Отправка сообщений
Для отправки сообщения через сокет служит функция uv_write().
int uv_write(uv_write_t* req, uv_stream_t* handle, const uv_buf_t bufs[], unsigned int nbufs, uv_write_cb cb);
Превый параметр uv_write_t* req — это какая-то переменная, которая нужна libuv для передачи данных. Вникать в смысл её присутствия в параметрах функции особо не стоит, но нужно её создать, например, так:
uv_write_t write_data;
Теперь данную переменную можно использовать многократно для последовательной отправки сообщений, но нельзя использовать одновременно для отправки нескольких сообщений.
Второй параметр uv_stream_t* handle — это сокет-отправитель.
Третий параметр const uv_buf_t bufs[] — это массив сообщений для отправки. Он состоит из элементов типа uv_buf_t, у которых есть поля len и base, что соответственно, должно содержать РАЗМЕР и АДРЕС.
Четвертый параметр unsigned int nbufs — количество элементов в массиве сообщений. В моем случае я всегда использовал только одно сообщение для отправки.
Пятый параметр uv_write_cb cb — это функция обратного вызова, которая вызывается, когда сообщение отправлено. Зачем она вообще нужна? Дело в том, что сообщения, которые отправляет пользователь должны храниться в памяти до тех пор, пока они не были отправлены. Т.е. когда срабатывает эта callback-функция, то это означает, что буфер данных, который содержал отправляемое сообщение теперь больше не нужен libuv. И теперь этот буфер опять переходит под контроль пользователя и его можно заполнить новыми данными и послать новое сообщение.
В моем случае я в одну структуру поместил и буфер данных, и непонятную, но нужную uv_write_t write_data. Поэтому они работают в паре в составе одной структуры.
Переход от структур libuv к более удобным типам данных
Представим себе, что у вас есть множество сокетов типа uv_tcp_t, которые принимают сообщения. Как я сказал чуть выше, из-за того, что чтение данных происходит по принципу потока и сообщения могут произвольно делиться на части, то нам дополнительно для каждого сокета понадобится буфер для хранения и анализа входящих данных. А вот теперь посмотрим еще раз на функцию обратного вызова OnReadTCP():
void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf)
и заметим, что она через параметр uv_stream_t* stream показывает нам сокет, который получил данные. Но… как вы свяжете сокет с нужным буфером данных? Допустим у вас 1000 сокетов и каждый чего-то там принимает. Вам нужно для каждого сокета положить принятые данные в его собственный буфер. Но ведь по сокету его буфер никак не определяется — внутри структуры сокета будет набор какой-то галиматьи без всяких признаков того, какому же из тысячи имеющихся буферов он соответствует.
Значит принцип организации сети должен быть совсем иным, и сейчас я постараюсь описать, с какой стороны можно решить этот вопрос.
Пока временно забудем о существовании libuv и попробуем создать общую структуру сервера.
Допустим у нас есть класс сервера под названием GNetServer. Объект этого класса всегда существует в единственном экземпляре и полностью берет на себя функции сервера. В моем конкретном случае сервер должен иметь два основных массива это:
- Массив игроков
- Массив существующих сеансов (или массив созданных игр)
Между этими двумя массивами должны быть установлены тесные связи, т.е. любой игрок должен знать, к какому сеансу он относится (если он уже присоединился к сеансу), а также любой сеанс должен знать, какие игроки входят в его состав.
Какие требования предъявляются к игрокам и сеансам? Главное требование — это быстрый доступ к нужному игроку или сеансу по его личному уникальному идентификатору. А ничего быстрее для поиска, чем просто сделать этот иднтификатор индексом игрока или сеанса в массиве придумать, наверное, невозможно. Итак, у нас каждый игрок имеет ID, который просто равен его номеру в общем массиве игроков. И теперь если другой игрок отправляет данные игрокам с идентификаторами 5, 10, 21 и 115, то сервер сразу сможет определить этих получателей просто используя их идентификаторы как индексы.
Теперь давайте определимся с тем, что же представляет из себя «игрок» с точки зрения сервера. На самом деле «игрок» — это и есть «сокет», только с некоторой дополнительной информацией. К дополнительной информации относятся следующие данные:
- Идентификатор игрока (он же индекс в массиве игроков)
- Буфер для принимаемых данных
- Время создания сокета
- Время последнего получения данных на сокет
- Признак UDP/TCP
- Имя игрока (требуется разве что для отладки и общей информации)
- Еще кое-какая не очень важная мелочь
Вся эта информация хранится в классе GNetSocket. Но обратите внимания, что в ней нет никамих данных от libuv. Сделано это для того, чтобы при некотором желании можно было заменить libuv на что-нибудь другое.
С моем случае имеется класс GNetSocketLibUV, который наследуется от GNetSocket. Для чего мне понадобился промежуточный класс GNetSocket, если в реальности создаются только объекты класса GNetSocketLibUV? Дело в том, что моя задача была максимально отделить библиотеку libuv от общей структуры сети. В результате основной файл сервера/клиента занимает более 7 тысяч строк, а файл специфичный для libuv — 600 строк. И если потребуется заменить libuv, то я смогу это сделать относительно легко. Также я использую принцип, когда объекты ключевых классов создаются не напрямую через new, а через виртуальные функции, например, так создается объект сокета для сервера:
GNetSocket* GNetServerLibUV::NewSocket(GNet* net)
{
return new GNetSocketLibUV(net);
}
Т.е. стоит мне заменить в одном месте return new GNetSocketLibUV(net); на какой-то другой тип объекта, как в программе будет создаваться только этот тип.
Класс GNetSocketLibUV переопределяет все абстрактные функции базового класса. Выглядит он так:
class GNetSocketLibUV : public GNetSocket
{
public:
void* sock;
public:
GNetSocketLibUV(GNet* net);
virtual ~GNetSocketLibUV();
// Создает UDP или TCP-сокет и, если указан listaen=true, то создается слушающий сокет
virtual bool Create(bool udp_tcp, int port, bool listen);
// Установка приконнекченного к серверу TCP-сокета в режим чтения
virtual bool SetConnectedSocketToReadMode();
// Удаляет сокет
virtual void Destroy();
// Определение IP-адреса для подключенного TCP-сокета
// own_or_peer показывает собственный или адрес с которым установлена связь нужно вернуть
virtual bool GetIP(CMagicString& addr, bool own_or_peer);
// Клиент запрашивает подключение к серверу (только для TCP-сокетов)
virtual bool Connect(NET_ADDRESS* addr);
// Сервер отвечает на подключение (только для TCP-сокетов)
virtual bool Accept();
virtual void SendTCP(NET_BUFFER_INDEX* buf);
virtual void SendUDP(NET_BUFFER_INDEX* buf);
virtual void ReceiveTCP();
virtual void ReceiveUPD();
};
В класс GNetSocketLibUV добавлена всего одна переменная void* sock, которая и будет являться почти что указателем на сокет libuv, но с некоторой оговоркой. Нам нужна возможность быстро по сокету libuv определять соответствующий ему сокет типа GNetSocket, в котором как раз лежит и буфер для чтения, и идентификатор игрока.
Как это сделать? Я добавил промежуточные структуры:
struct NET_SOCKET_PTR
{
GNetSocket* net_socket;
};
struct TCP_SOCKET : public NET_SOCKET_PTR, public uv_tcp_t
{
};
struct UDP_SOCKET : public NET_SOCKET_PTR, public uv_udp_t
{
};
Значит так… теперь у нас есть новая структура для TCP-сокета под названием TCP_SOCKET и новая структура для UDP-сокета, которая называется UDP_SOCKET. Но… у обеих этих структур перед структурой сокетов появилось новое поле, которое является указателем на родительский объект класса GNetSocket.
Теперь еще одно важное замечание. В программе нигде не должны создаваться «родные» сокеты libuv, а только сокеты типа TCP_SOCKET и UDP_SOCKET. Сразу после создания в поле net_socket должен записываться адрес объекта GNetSocket, в составе которого создавался TCP_SOCKET или UDP_SOCKET.
Практически, создание сокета выглядит так:
// Создает UDP или TCP-сокет и, если указан listen=true, то создается слушающий сокет
bool GNetSocketLibUV::Create(bool udp_tcp, int port, bool listen)
{
GNetSocket::Create(udp_tcp, port, listen);
uv_loop_t* loop=GetLoop(net);
if (udp_tcp)
{
sock=malloc(sizeof(TCP_SOCKET));
memset(sock, 0, sizeof(TCP_SOCKET));
((TCP_SOCKET*)sock)->net_socket=this;
...
...
...
}
Теперь, когда у нас void* sock является адресом TCP_SOCKET или UDP_SOCKET, и мы всегда знаем, что первыми в структуре всегда будет стоять указатель на основной сокет GNetSocket* net_socket, то задача по «быстрой установке соответствия» почти решена.
Добавляем пару функций, которые помогут дегко получать нужные данные.
Если sock — это TCP_SOCKET, то передав адрес sock в следующую функцию легко извлекается TCP-сокет libuv:
uv_tcp_t* GetPtrTCP(void* ptr)
{
return (uv_tcp_t*)(((char*)ptr)+sizeof(void*));
}
Если sock — это UDP_SOCKET, то передав адрес sock в следующую функцию легко извлекается UDP-сокет libuv:
uv_udp_t* GetPtrUDP(void* ptr)
{
return (uv_udp_t*)(((char*)ptr)+sizeof(void*));
}
А функция GetNetSocketPtr(адрес uv_tcp_t или uv_udp_t сокета) позволяет получить соответствующий этому сокету адрес нашего основного сокета типа GNetSocket.
GNetSocket* GetPtrSocket(void* ptr)
{
return *((GNetSocket**)ptr);
}
GNetSocket* GetNetSocketPtr(void* uv_socket)
{
return GetPtrSocket(((char*)uv_socket)-sizeof(void*));
}
Как этим пользоваться на практике? Например, нужно перевести TCP-сокет в режим чтения:
// Установка приконнекченного к серверу TCP-сокета в режим чтения
bool GNetSocketLibUV::SetConnectedSocketToReadMode()
{
if (udp_tcp)
{
uv_tcp_t* tcp=GetPtrTCP(sock);
int r=uv_read_start((uv_stream_t*)tcp, OnAllocBuffer, OnReadTCP);
return (r==0);
}
return false;
}
Обратите внимание, что наш sock превращается в uv_tcp_t* tcp с помощью GetPtrTCP(sock), а уже его можно передавать в функцию uv_read_start().
Теперь как в моем случае выглядит callback-функция OnReadTCP():
void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf)
{
GNetSocket* socket=GetNetSocketPtr(stream);
if (nread>0)
{
NET_BUFFER* recv_buffer=socket->net->GetRecvBuffer();
assert(buf->base==(char*)recv_buffer->GetData());
recv_buffer->SetLength(nread);
socket->ReceiveTCP();
}
else
{
// это ошибка, значит связь разорвана
socket->net->OnLostConnection(socket);
}
}
Первая же строчка:
GNetSocketLibUV* socket=(GNetSocketLibUV*)GetNetSocketPtr(stream);
получает адрес объекта GNetSocket, для которого будет выполнена функция socket->ReceiveTCP(), которая и осуществляет реальный прием сообщений полученных сокетом. Она уже положит эти данные в собственный буфер сокета, проверит на тот факт, что сообщение получено полностью и затем передаст его на обработку серверу либо клиенту (у меня большая часть кода у сервера и клиента совпадает).
Еще, пожалуй, приведу пример удаления сокета:
// Удаляет сокет
void GNetSocketLibUV::Destroy()
{
if (sock)
{
if (udp_tcp)
{
uv_tcp_t* tcp=GetPtrTCP(sock);
uv_close((uv_handle_t*)tcp, OnCloseSocket);
((TCP_SOCKET*)sock)->net_socket=NULL;
}
else
{
uv_udp_t* udp=GetPtrUDP(sock);
int r=uv_read_stop((uv_stream_t*)udp);
assert(r==0);
uv_close((uv_handle_t*)udp, OnCloseSocket);
((UDP_SOCKET*)sock)->net_socket=NULL;
}
sock=NULL;
}
GNetSocket::Destroy();
}
Обратите здесь внимание на строку
((TCP_SOCKET*)sock)->net_socket=NULL;
, т.е. сокет libuv здесь не удаляется, он становится вообще сам по себе, так как основной объект GNetSocket уже не будет иметь связи с этим сокетом. Но когда libuv завершит все свои дела с сокетом, то неминуемо сработает callback-функция OnCloseSocket(), в которой и будет выполнен free(). Таким образом утечки памяти не произойдет.
На этом, я думаю, что разговор про библиотеку libuv можно заканчивать. Я постарался пояснить суть принципов, которые лежат в основе её работы и, думаю, что принципы эти, практически, одинаковы для большинства подобных решений, включая WinSock. Возможно в моей трактовке не хватает примеров кода, но их не очень сложно найти в Интернете по названиям функций. Я же пытался пояснить, что с этими функциями нужно делать и как они взаимодействуют друг с другом. Вполне возможно, что в моем понимании есть какие-то неточности, так как моей целью было доделать игру, а не стать экспертом в области сетевого программирования, поэтому я разобрался с этим делом по принципу «необходимо и достаточно».
Структура сетевого клиента GNetClient
Клиент в разные моменты времени должен вести себя абсолютно по разному, например, в самом начале он должен подключится к серверу и создать или присоединиться к сеансу, а когда игра уже началась, он должен обмениваться с другими игроками данными, полученными от мыши и клавиатуры.
Можно разбить логику работы клента на стадии и описать работу каждой стадии отдельно.
В моем случае имеются следующие стадии:
GNetStadyEnumSession / NET_STADY_ENUM_SESSION — стадия получения списка существующих игровых сеансов.
GNetStadyJoinSession / NET_STADY_JOIN_SESSION — стадия подключения в игровому сеансу и настройки собственного игрока.
GNetStadyCreateSession / NET_STADY_CREATE_SESSION — стадия, которая позволяет создать сеанс, настраивать его и собственного игрока.
GNetStadyStartGame / NET_STADY_START_GAME — стадия запуска сетевой игры.
GNetStadyGame / NET_STADY_GAME — стадия игры.
GNetStadyMigrationHost / NET_STADY_MIGRATION_HOST — стадия миграции хоста.
Индексы стадий объявлены следующим образом:
enum NET_STADY {NET_STADY_ENUM_SESSION, NET_STADY_JOIN_SESSION, NET_STADY_CREATE_SESSION, NET_STADY_START_GAME, NET_STADY_GAME, NET_STADY_MIGRATION_HOST, NET_STADY_NO=-1};
Объект каждой стадии создается заранее и хранится в массиве стадий внутри GNetClient.
// Класс, который является сетевым клиентом
class GNetClient : public GNet
{
protected:
NET_STADY net_stady; // текущая стадия
int k_net_stady; // количество стадий
GNetStady** m_net_stady; // массив со стадиями
...
...
...
}
GNetClient::GNetClient() : GNet()
{
net_stady=NET_STADY_NO;
k_net_stady=6;
m_net_stady=new GNetStady*[k_net_stady];
m_net_stady[NET_STADY_ENUM_SESSION]=new GNetStadyEnumSession(this);
m_net_stady[NET_STADY_JOIN_SESSION]=new GNetStadyJoinSession(this);
m_net_stady[NET_STADY_CREATE_SESSION]=new GNetStadyCreateSession(this);
m_net_stady[NET_STADY_START_GAME]=new GNetStadyStartGame(this);
m_net_stady[NET_STADY_GAME]=new GNetStadyGame(this);
m_net_stady[NET_STADY_MIGRATION_HOST]=new GNetStadyMigrationHost(this);
...
...
...
}
Базовым для всех стадий является класс GNetStady, от которого наследуются все остальные стадии:
// Класс, работающий со стадиями сетевой игры
class GNetStady
{
protected:
GNetClient* owner; // указатель на родительский объект-клиент GNetClient
unsigned int stady_period;
unsigned int stady_tick;
public:
GNetStady(GNetClient* owner);
virtual ~GNetStady(){}
virtual bool OnStart(NET_STADY previous, void* init);
virtual void OnFinish(NET_STADY next){}
virtual void OnUpdate();
// Функция вызывается периодически через время, которое определяется переменной stady_period (если она не равна 0)
virtual void OnPeriod(){}
// Проверка на корректность полученного сообщения
virtual bool IsMessageCorrected(int message_type){return false;}
};
Пройдемся коротко по функциям стадии.
-
virtual bool OnStart(NET_STADY previous, void* init)
Функция выполняется когда стадия становится текущей. Практически, это инициализация стадии, которая выполняется в момент её активации. Если эта функция возвращает false, то это означает, что сделать данную стадию текущей невозможно. В качестве параметров эта функция получает индекс предыдущей стадии NET_STADY previous и произвольные данные для инициализации void* init, которые почти нигде не используются.
Выбор новой стадии происходит с помощью bool GNetClient::SetStady(NET_STADY stady, void* init).
-
virtual void OnFinish(NET_STADY next)
Функция выполняется, когда стадия перестает быть текущей, т.е. эта функция служит для того, чтобы выполнить какие-то действия по деактивации стадии. Часто она просто является пустой функцией.
Пользователь для выбора новой стадии вызывает функцию SetStady():
// Установка стадии bool GNetClient::SetStady(NET_STADY stady, void* init) { if (stady!=net_stady) { if (net_stady!=NET_STADY_NO) { // необходимо завершить предыдущую стадию m_net_stady[net_stady]->OnFinish(stady); } if (stady!=NET_STADY_NO) { // необходимо инициализировать новую стадию if (!m_net_stady[stady]->OnStart(net_stady, init)) return false; } net_stady=stady; } return true; }
-
virtual void OnUpdate()
Функция, которая обслуживает работу стадии. Эта функция вызывается постоянно, совместно с обновлением состояния игры.
Вызов GNetStady::OnUpdate() выполняется из GNetClient::OnUpdate():
void GNetClient::OnUpdate() { if (net_stady!=NET_STADY_NO) m_net_stady[net_stady]->OnUpdate(); }
-
virtual void OnPeriod()
Функция, которая вызывается только в случае, когда стадия должна выполнять какие-то периодические действия. Периодичность определяется значением переменной GNetStady::stady_period, которая должна содержать частоту вызова OnPeriod(). Например, если stady_period=500, то это означает, что вызов функции OnPeriod() будет происходить примерно 1 раз в полсекунды.
На самом деле функция OnPeriod() осуществляется из OnUpdate(), которая для базового класса определена так:
void GNetStady::OnUpdate() { if (stady_period) { unsigned int tick=owner->game->platform->GetTick(); if (tick>=stady_tick+stady_period) { stady_tick=tick; OnPeriod(); } } }
-
virtual bool IsMessageCorrected(int message_type)
Функция, которая проверяет на корректность принимаемого сообщения по его типу. Естественно, что каждое сетевое сообщение состоит из заголовка, типа и данных. Эта функция как раз и проверяет этот тип полученного сообщения на правильность для данной стадии. Если тип сообщения корректен, то он будет отправлен на обработку, иначе сообщение будет просто отброшено.
Зачем это нужно? Ну, во-первых, мало ли что может прийти по сети. А, во-вторых, сетевое взаимодействие имеет много общего с многопоточностью, т.е. после отправки сообщения, его получением и обработкой принимающей стороной может лежать N-ое количество времени.
Например, когда игроки меняют свои настройки в сеансе, то сообщения о новых настройках передаются всем остальным участникам сеанса. А теперь представьте, что какой-то игрок покинул сеанс (нажал Cancel) и перешел на стадию NET_STADY_ENUM_SESSION, чтобы выбрать себе другой сеанс. Тогда в момент выхода из сеанса он тоже уведомит других игроков о своем уходе, но проблема в том, что пока те получат его сообщение и среагируют на него проходит время. За этот период времени остальные игроки запросто могут продолжать слать уже покинувшему сеанс игроку сообщения о настройках сеанса. И именно эти уже ненужные сообщения ушедший игрок должен безжалостно отсекать.
Управление стадиями сетевого клиента
Всегда какая-то стадия является текущей, за исключением ситуации, когда клиент еще не подключился к серверу.
Для начала работы с сетью используется функция:
// Инициализация сетевой игры
virtual bool GGame::StartNet(const char* ip);
Если ip=NULL, то запускается собственный локальный сервер, иначе ip должен быть IP-адресом Интернет-сервера, например, 234.123.34.18:57.
Для завершения работы с сетью имеется функция:
// Завершение сетевой игры
virtual void StopNet();
Эта функция гарантированно удаляет все признаки сети, т.е. останавливает локальный сетевой сервер, если он имеется, и уничтожает объект клиента GNetClient.
Для начала работы с сетью нужно вызвать функцию bool GGame::StartNet(const char* ip). Если указан ip-адрес, то функция инициализирует сеть и попробует установить соединение с сервером, находящимся по указанному адресу. Если сервер ответил и разрешил соединение, то функция вернёт true, иначе false. Все дальнейшие обращения к серверу будут выполняться через установленное TCP-соединение. Если же ip=NULL, то никакого подключения к серверу функция не выполняет. Вместо этого она создает UDP-сокет, чтобы с его помощью искать сервера в локальной сети через широковещательный запрос.
Если при выполнении функции StartNet() не возникло проблем, то сеть начинает выполнять стадию NET_STADY_ENUM_SESSION, т.е. искать созданные игровые сеансы. Происходит это следующим образом… в функции GNetStadyEnumSession:: OnPeriod(), которая автоматически вызывается 1 раз в полсекунды, происходит отправка сообщения типа MESSAGE_TYPE_ENUM_SESSION. Сообщение посылается либо только Интернет-серверу по заранее установленному соединению, либо широковещательным запросом в локальную сеть. В любом случае если Интернет-сервер или какой-то сервер в локальной сети получают это сообщение, то они реагируют одинаково, а именно: шлют вопрошающему ответ в виде сообщения MESSAGE_TYPE_ENUM_SESSION_REPLY, в котором перечислены все имеющиеся на сервере сеансы. Каждый сеанс имеет краткое описание в виде времени создания сеанса, ID хоста, имени карты, количества игроков и их характеристик, признак пароля на сеансе, а также IP-адрес сервера, через который клиент сможет подключиться — это очень важно для локальной сети, так как клиент в этом случае отправляет запрос широковещательно и он должен знать адрес сервера, который прислал ему ответ, чтобы произвести подключение.
Получив сведения о найденных сеансах, клиент вызывает виртуальную функцию virtual void GGame::OnEnumSession(GSessionList* sessions, int count_general_session). Эта функция в классе GGame не определена, так как её задача, практически, отобразить список сеансов пользователю. Так как GGame — это универсальный базовый класс, то он не берет на себя такие задачи, так как ничего не знает о конкретной игре, где он может использоваться. Поэтому данная функция в моем случае переопределяется в классе GGameOnimodLand и именно она сортирует полученные сеансы по времени создания и показывает их пользователю добавляя строки в компонент GListBox.
Если пользователь выбрал сеанс и жмёт "Присоединиться", то происходит следующее. Клиент просто запускает стадию NET_STADY_JOIN_SESSION, которая в своей функции GNetStadyJoinSession::OnStart(), делает попытку подключиться к сеансу. Эта попытка может быть неудачной по разным причинам, например, игра была запущена или подключился другой игрок, и места в сеансе больше нет. В любом случае разрешение на подключение выдает хост (ни в коем случае не сервер).
Как это происходит практически?
В случае с локальной сетью сначала необходимо подключиться к серверу. Для этого используется функция bool GNetClient::ConnectToServer(const char* ip), которая в случае успеха возвращает true. IP-адрес сервера берется из информации о сеансе. Перед установкой соединения сначала убивается UDP-сокет, так как он теперь не нужен и вместо него создается TCP-сокет, через который и будет устанавливаться соединение. Клиент обращается к серверу с помощью CONNECT, а сервер должен принять запрос и отреагировать через ACCEPT. Если соединение установлено, то необходимо обменяться "рукопожатием".
Для чего это нужно? Дело в том, что к серверу может подключиться кто угодно из совесем другой программы. "Рукопожатие" идентифицирует клиента в глазах сервера и именно после успешного "рукопожатия" сервер позволяет клиенту начать обмен сообщениями. Само "рукопожатие" выполняется через сообщение MESSAGE_TYPE_HELLO, которое дополнительно снабжается всякой служебной информацией типа версия клиента, лицензионный ключ и т.д. Сервер проверяет эту информацию и возвращает клиенту сообщение MESSAGE_TYPE_HELLO_REPLY, которое содержит уникальный ID клиента. Всё дальнейшее общение с сервером и другими игроками клиент выполняет через этот ID. Если сервер вернул ID=0, то это означает, что клиент не принят сервером. В этом случае сервер дополнительно передает текст, в котором поясняет клиенту причину отказа. Этот текст может быть отображен на стороне клиента в виде, например, GMessageBox. Обратите внимание, что к Интернет-серверу подключение происходит по аналогичному принципу.
Если клиент подключен к серверу, то он сразу должен передать хосту просьбу о подключении к сеансу. Однако хост может защитить сеанс паролем, чтобы подключались только "свои". Из информации о сеансе определяется факт наличия такого пароля. В этом случае пользователю сначала должно быть предложено ввести пароль. Далее выполняется функция bool GNetClient::ConnectSession(NET_JOIN_DATA* join), которая в свою очередь отправляет хосту сообщение типа MESSAGE_TYPE_CONNECTING. Сообщение отправляется на ID хоста, который опять же берется из информации о сеансе. Сначала сообщение получает сервер, который анализирует список получателей и передает сообщение хосту.
Получив сообщение, хост смотрит, есть ли возможность принять игрока в сеанс, проверяет пароль (если он имеется) и принимает решение. В случае положительного ответа хост сначала выбирает для нового игрока игровой слот с помощью виртуальной функции virtual int GGame::GetIndexOfConnectedPlayer(), и включает игрока в сеанс. Дополнительно у хоста срабатывает функция virtual void GGame::OnNewPlayer(int index), которая позволяет проделать какие-то действия из-за появления нового участника. Далее хост посылает клиенту ответ в виде сообщения MESSAGE_TYPE_CONNECT_REPLY, где он, в случае положительного ответа, передает назначенный клиенту индекс слота в сеансе, всю информацию о состоянии других игроков в сеансе, а также некоторую дополнительную информацию. Получив сообщение MESSAGE_TYPE_CONNECT_REPLY, клиент запоминает свой слот и инициализирует свои переменные сеанса полученными от сервера данными.
Теперь новый игрок может настраивать свои характеристики в сеансе и обмениваться чат-сообщениями. Чтобы хост мог запустить игру каждый участник сеанса должен нажать кнопку "Я готов", без этого у хоста кнопка "Старт" будет находиться в неактивном состоянии.
Информация об игровом сеансе
Информация о сеансе должна быть организована так, чтобы не зависеть от конкретной игры. Например, у меня нет никакого желания менять структуру у своей оболочки, если вдруг я захочу так сказать «вспомнить детство золотое» и снова заняться играми. Значит нужно сделать так, чтобы поля в описании сеанса были произвольными и их анализом занималась только конкретная игра, но никогда не сервер или клиент. Однако некоторые поля всё же должны быть четко определены, так как кое-что сервер всё же обязан знать о сеансе, например, его имя. Также нужно однозначно трактовать некоторую информацию об игроках, входящих в сеанс, например, количество и статус "кто это" (человек, компьютер, открытый слот, закрытый слот).
Всю эту информацию я поместил в структуру struct NET_SESSION_INFO. В составе этой структуры есть массив с игроками NET_PLAYER** m_player; и их количество int k_player;
Но самое важное — имеются поля, которые могу хранить произвольную информацию:
int length_info; // размер доп. данных
char* info; // массив доп. данных
Такие же поля имеются в структуре NET_PLAYER и они также позволяют хранить произвольную информацию для любого игрока.
Далее, в структуру NET_SESSION_INFO добавляем следующие функции и операторы:
virtual void Serialize(CMagicStream& stream); // запись/чтение структуры из потока
NET_SESSION_INFO& operator=(const NET_SESSION_INFO& si); // оператор присваивания
bool operator==(const NET_SESSION_INFO& si); // оператор проверяющий на равенство
bool operator!=(const NET_SESSION_INFO& si) // оператор проверяющий на неравенство
Зачем всё это нужно?
Постараюсь пояснить одну важную штуку, которая, как я думаю, понятна далеко не всем. Применение операторов, на мой взгляд, является довольно опасным делом. Проблема в том, что очень часто в структуру по ходу разработки добавляются новые переменные. И часто их даже можно забыть проинициализировать в конструкторе. А уж позабыть про то, что надо эти переменные еще и добавить в оператор сравнения и равенства — это со мною бывало не раз, после чего я иногда очень гневался на свою «тупую башку» за забывчивость. Так вот… в результате я пришел к следующему решению. Обычно важные структуры имеют в своем составе сериализацию или, проще говоря, умеют читать свои данные из потока и писать их в поток. Чаще всего поток — это обычный файл, куда пишутся байты, но будет гораздо лучше, если потоком сможет быть также и область памяти. В моем случае я написал класс CMagicStream и из него породил классы CMagicStreamFile и CMagicStreamMemory. Поэтому функция virtual void Serialize(CMagicStream& stream); умеет работать и с файлами, и с ОЗУ в зависимости от того, объектом какого класса является в реальности stream.
К слову говоря, есть у меня и еще один тип класса CMagicStreamVirtualFile, который входит в мою «оболочку» и предназначен для работы с виртуальным диском. Виртуальный диск — это парочка файлов, внутри которых находится собственная файловая система. Я использовал свой вирутальный диск, чтобы разместить внутри него ресурсы игры. Виртуальный диск можно открыть через CMagicString GPlatform::OpenVirtualDrive(const char* path) указав в качестве пути файл вирутального диска. В результате будет возвращен путь типа :0 который потом можно использовать в оболочке для работы с файлами внутри виртуального диска. Важно здесь то, что функции «оболочки», работающие с файловой системой, будут понимать такой путь и перенаправлять запросы к файлам куда нужно, например, функция CMagicStream* GPlatform::OpenStream(const char* file, int mode) работает по такому универсальному принципу и корректно обратиться на виртуальный диск, если file имеет соответствующий путь, указывающий на него. Это же касается и ситуации с "текущей папкой" — никто не мешает сделать текущей папку на виртуальном диске.
Так вот… вернемся к сути. Я говорил о том, что для меня операторы могут быть опасны из-за моей забывчивости. Чтобы свести риск к минимуму я поступаю так: делаю для структуры функцию сериализации в бинарном виде, потом все операторы пропускаю через эту функцию. Т.е. например, если мне надо написать оператор присваивания, то я вместо того, чтобы копировать поля структуры по одному, создаю в ОЗУ поток типа CMagicStreamMemory на запись и выполняю для него Serialize() копируемого объекта структуры, далее я создаю такой же поток на чтение и для результарующего объекта структуры тоже выполняю Serialize() с этого же участка памяти. Получается такой Save + Load через ОЗУ. Точно также можно поступить и с оператором сравнения — записываем данные сравниваемых объектов в два разных потока, а потом начинаем сравнивать их побайтно. Этот способ разумеется, медленне, чем если бы опреатор сравнивал каждое поле с каждым. Но для узких мест всегда можно использовать традиционный вариант. Достоинство же сериализации в том, что она применяется еще и для обычного Save/Load данных. А тут забывчивость всплывает достаточно быстро, по крайней мере, в моем случае, так как испорченные данные или несохраненные поля гораздо активнее бросаются в глаза.
Кроме того, сериализация прекрасно подходит для копирования своих данных через буфер обмена. А в случае с сетью… обратите внимание, что сетевое взаимодействие сводиться к передачи сообщений, которые представляют из себя поля "тип", "размер" и "произвольные данные". И использвание бинарной сериаллизации как раз и позволяет превратить любые типы данных в поток байтов, который можно передать по сети, а затем снова превратить в исходные данные на принимающей стороне.
Теперь я наконец-то добрался до сути конфигурирования сетевого сеанса. Сетевой клиент не должен ничего знать об игре и не должен знать о большинстве настроек игроков. Например, флаг "Я готов" уж точно ему не нужен — он нужен именно игре, а сетевой клиент служит лишь для передачи сообщений по сети.
Я сделал так. В GGame объявил 4 пустые вирутальные функции, которые должны быть определены уже в классе GGameOnimodLand.
virtual bool GGame::GetSessionInfoStruct(NET_SESSION_INFO* si); // заполняет структуру сеанса NET_SESSION_INFO* si данными из игры.
virtual void GGame::SetSessionInfoStruct(NET_SESSION_INFO* si); // копирует данные из структуры сеанса NET_SESSION_INFO* si в игру.
virtual bool GGame::GetSessionPlayerStruct(int index, NET_PLAYER* np); // заполняет структуру сетевого игрока NET_PLAYER* np данными об этом игроке из игры. Параметр index содержит слот игрока.
virtual void GGame::SetSessionPlayerStruct(int index, NET_PLAYER* np); // копирует данные из структуры сетевого игрока NET_PLAYER* np в игрока в игре.
Практически, эти функции универсально передают данные от сетевого клиента в игру и обратно. При этому функции GetSessionInfoStruct / SetSessionInfoStruct работают с данными всего сеанса, включая и данные об игроках. А функции GetSessionPlayerStruct / SetSessionPlayerStruct работают с данными по конкретному игроку. Естественно, что функции для игроков используются функциями сеансов, так как NET_SESSION_INFO содержит массив объектов NET_PLAYER.
После такого подхода сама игра для сетевого клинета превращается в «черный ящик», из которого «что-то» приходит и который «что-то» принимает. И сейчас важный момент, который нужен для того, чтобы упростить процесс конфигурирования игрока. Вот представьте, что у игрока существует много настроек и что они могут меняться, например, можно менять цвет своей команды или выбирать расу. Да мало ли что можно придумать в перспективе и желательно, чтобы не пришлось потом исправлять структуру программы.
Я сделал так: у стадии NET_STADY_JOIN_SESSION по таймеру вызывается функция void GNetStadyJoinSession::OnUpdate() и она выполняет в паре строк то, что решает проблему универсальности.
Стадия GNetStadyJoinSession имеет переменную NET_SESSION_INFO* copy_session; в которой храниться копия о состоянии сеанса за прошлый такт. Я всё же приведу значимую часть кода целиком.
void GNetStadyJoinSession::OnUpdate()
{
GNetStady::OnUpdate();
// необходимо сравнить старую конфигурацию с новой
NET_PLAYER* current_player=current_session->m_player[index_player];
NET_PLAYER* copy_player=copy_session->m_player[index_player];
owner->game->GetSessionPlayerStruct(index_player, current_player);
if (*copy_player!=*current_player)
{
// необходимо переслать новую конфигурацию всем участникам сеанса
*copy_player=*current_player;
if (current_player->type==PLAYER_MAN)
{
GMemWriter* wr1=owner->wr1;
wr1->Start();
(*wr1)<<index_player;
current_player->Serialize(*wr1);
MEM_DATA buf;
wr1->Finish(buf);
// необходимо извлечь участников сеанса, кроме себя самого
int k_receiver=owner->RefreshReceiverList();
NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_INFO, buf.length, buf.data, k_receiver, owner->m_receiver);
owner->GetMainSocket()->SendMessage(result);
}
}
}
Практически, тут происходит следующее. Переменная index_player является номером слота, который принадлежит игроку в сеансе.
Строка owner->game->GetSessionPlayerStruct(index_player, current_player); берет из игры текущие настройки этого же игрока и дальше просто сравнивает их с теми, которые помнит клиент:
if (*copy_player!=*current_player)
и если вдруг имеется несоответствие, то сначала копия приводится в соответствие *copy_player=*current_player; а затем всем участникам сеанса отправляет сообщение типа MESSAGE_TYPE_PLAYER_INFO, в котором передаются новые настройки игрока.
В чем большое преимущество такого подхода? Дело в том, что сама игра вообще не должна следить за отправкой конфигурации другим игрокам. Стоит поменять в конфигурации хотя бы 1 байт, как GNetStadyJoinSession::OnUpdate() сразу заметит это изменение и автоматически разошлет новые данные всем участникам сеанса. При этом GNetStadyJoinSession::OnUpdate() ничего не знает про реальные данные, которые могут конфигурироваться, ведь оператор сравнения работает через сериализатор, а там сравнивается длина потока байтов и сам поток с случае равенства длины.
В примере к статье, структура с конфигурацией игрока выглядит так:
struct PlayerCfg
{
int type;
CMagicString name;
unsigned int player_id;
unsigned int color;
bool ready;
void Serialize(CMagicStream& stream)
{
if (stream.IsStoring())
{
stream<<type;
stream<<name;
stream<<player_id;
stream<<color;
stream<<ready;
}
else
{
stream>>type;
stream>>name;
stream>>player_id;
stream>>color;
stream>>ready;
}
}
};
А в игре поля совсем другие и их гораздо больше. Тем не менее такой подход прекрасно работает с точки зрения универсальности.
Создание сеанса
Однако, прежде чем присоединяться к сеансу, необходимо, чтобы кто-то этот сеанс создал. Для этого существует стадия NET_STADY_CREATE_SESSION. Класс этой стадии наследуется от стадии NET_STADY_JOIN_SESSION:
class GNetStadyCreateSession: public GNetStadyJoinSession
Это сделано по причине того, что эти стадии во многом похожи и серьезно отличаются разве что функцией OnStart(), которая выполняет либо создание сеанса, либо присоединение к сеансу. В стадии NET_STADY_CREATE_SESSION точно также работает проверка на изменение конфигурации, но дополнительно имеется проверка и на изменение каждого игрока в сеансе, ведь хост контролирует весь сеанс целиком и может, например, удалять других игроков.
Кстати, сетевой клиент всё же немного участвует в анализе данных о конфигурации игрока. За конфигурацию отвечает сообщение MESSAGE_TYPE_PLAYER_INFO, которое клиент анализирует следующим образом. В момент приема сообщения он запоминает был ли игрок, для которого пришла новая конфигурация, живым человеком (type=PLAYER_MAN). После приема сообщения текущая конфигурация заменяется на новую. Но клиент проверяет только поле type на предмет того, что оно всё еще равно PLAYER_MAN. И если вдруг поле изменилось на PLAYER_OPENED, то это может означать, например, что хост удалил игрока из сеанса и теперь слот открыт. Клиент участвует в обработке этой ситуации в результате чего вызывается bool GNetClient::LostPlayer(unsigned int player_id), которая означает что свзяь с игроком потеряна. Далее всё это доходит до игры в виде одного из вариантов:
// Хост покинул сеанс (предназначено для остальных игроков сеанса)
virtual void GGame::OnCancelSession();
// Текущий игрок был отсоединен от сеанса хостом
virtual void GGame::OnDeletingFromSession();
Функция bool GNetStadyCreateSession::OnStart(NET_STADY previous, void* init) вызывается, когда игрок нажимает кнопку «Создать игру». Если это игра по локальной сети, то необходимо сначала запустить собственный сервер и присоединиться к нему:
// Создание игрового сеанса
bool GNetClient::CreateSession()
{
bool is=false;
if (!internet)
{
// необходимо запустить сервер
if (StartLocalServer())
{
is=ConnectToServer("127.0.0.1");
}
else
is=false;
}
else
{
is=true;
}
return is;
}
В функции ConnectToServer(«127.0.0.1») UDP-сокет уничтожается и создается TCP-сокет. Далее происходит установка соединения с сервером, который запускается на том же самом компьютере функцией StartLocalServer(). IP-адрес сервера равен «127.0.0.1», что означает «тот же самый компьютер».
Далее хост вызывает у себя функцию virtual void GGame::OnCreateSession(int index_player), которая в моем случае, лишь устанавливает для хоста его слот, который всегда равен 0.
Далее хост с помощью функции void GNetStadyCreateSession::OnPeriod() начинает периодически сообщать серверу о состоянии сеанса. Эта функция вызывается автоматически 1 раз в полсекунды. Она отправляет серверу сообщение типа MESSAGE_TYPE_SESSION_INFO. Это сообщение отправляется не всегда, а только лишь в случае, когда настройки сеанса были изменены. Здесь используется тот же принцип, что и с изменением конфигурации игрока.
Сервер, получив сообщение MESSAGE_TYPE_SESSION_INFO, сначала проверяет есть ли уже такой сеанс в списке его сеансов и, если его нет, то производит добавление нового сеанса. Отправитель сообщения добавлется в новый сеанс в качестве первого участника и хоста.
Далее сервер будет рассылать информацию об имеющихся сеансах в ответ на запрос от клиента MESSAGE_TYPE_ENUM_SESSION.
Стадия NET_STADY_CREATE_SESSION длится до тех пор, пока игрок не нажмет кнопку "Старт", чтобы инициировать запуск игры. Практически, в этот момент устанавливается стадия NET_STADY_START_GAME через вызов net->SetStady(NET_STADY_START_GAME, NULL);.
Сообщение MESSAGE_TYPE_START_GAME отправляется автоматически из функции
void GNetStadyCreateSession::OnFinish(NET_STADY next), которая вызывается в момент завершения стадии NET_STADY_CREATE_SESSION. Здесь же хост сообщает серверу, что сеанс теперь закрыт для присоединения и не нужно о нем сообщать другим игрокам.
Сообщение MESSAGE_TYPE_START_GAME передается сервером всем участникам сеанса. Получив его все они также переключаются на стадию NET_STADY_START_GAME.
Далее основная работа стадии запуска игры выполняется в функции:
void GNetStadyStartGame::OnPeriod(), которая выполняет обратный отсчет времени от 5 до 1 через функцию virtual void GGame::OnStartNetCounter(int counter); Практически, происходит вывод цифр 5,4,3,2,1 в область чата. Далее вызывается virtual void GGame::OnStartNetGame(), которая запускает процесс подготовки игрового сеанса к запуску. В этот момент должна загрузиться игровая карта и по ней должны быть расставлены игроки. Обратите внимание, что весь этот процесс выполняется на каждом компьютере независимо. Когда все данные будут инициализированны, то функция virtual bool GGame::IsNetGameLoaded() должна вернуть true. Эта функция вызывается постоянно из GNetStadyStartGame::OnPeriod() и пока возвращается false, сетевой клиент полагает, что инициализация игры продолжается. Как только возвращается true, тут же всем остальным игрокам отправляется сообщение MESSAGE_TYPE_PLAYER_STARTED. В тот момент, когда сетевой клиент обнаруживает, что получил сообщение MESSAGE_TYPE_PLAYER_STARTED уже от всех участников сеанса, он переходит на стадию NET_STADY_GAME и это означает начало игры.
Функция bool GNetStadyGame::OnStart(NET_STADY previous, void* init) тут же вызывает virtual void GGame::OnLaunchNetGame(), а это и есть запуск. Всё. Дальше начинается игра.
Сетевая игра
Стадия NET_STADY_GAME контролирует весь игровой процесс через функцию void GNetStadyGame::OnUpdate(). Практически, эта стадия отправляет команды, которые игрок ввел с помощью мыши и клавиатуры за определенный период времени. Также эта стадия ожидает точно таких же данных от других игроков.
Команды пользователя передаются через сообщение MESSAGE_TYPE_PLAYER_GAME. У стадии GNetStadyGame имеются поля:
int k_player;
PLAYER_MESSAGE* m_player;
которые служат для приема команд от других игроков. В структуре PLAYER_MESSAGE имеется буфер для приема сообщений NET_BUFFER next_message; однако его предназначение совсем не такое, каким оно может показаться на первый взгляд. Дело в том, что существует понятие номер сетевого такта — это такая переменная которая отсчитывает такты увеличиваясь от 0 до бесконечности, пока игра не закончится. Сетевой такт увеличивается только в том случае, если сетевой клиент получил команды от всех игроков за текущий сетевой такт. Иначе происходит ожидание, и игра замирает. Но так как обычно качество связи позволяет доставлять сообщения достаточно стабильно, то эти задержки происходят незаметно для игрока.
Если клиент всё же начинает длительно ждать, то он сообщает об этом игре через функцию virtual void GGame::OnWaitingPlayers(unsigned int dtime, int k_player_id, unsigned int* m_player_id) и задача игры уже вывести этот список игроков на экран. Время ожидания ограниченно и клиент следит, чтобы ожидание не было бесконечным. Когда исчерпывается лимит времени, то клиент начинает отсекать проблемных игроков, объявляя их проигравшими, что сразу же приводит к продолжению игры либо её завершению по причине победы.
Если клиенту получил от другого игрока команды за текущий такт, то они сразу передаются игре с помощью функции virtual void GGame::SetPlayerNetMessage(unsigned int sender, MEM_DATA& message). Но если полученное сообщение сразу передается в игру, то наверное не очень понятно, зачем нужен еще один буфер NET_BUFFER next_message? А вот зачем.
Как я уже говорил, сетевое взаимодействие сильно напоминает многопоточность, когда выполнение каких-то действий может откладываться на неопределенный срок по причине синхронизации потоков. Так вот… в сетевой игре запросто может образоваться ситуация, когда один компьютер стал обгонять другой на 1 сетевой такт. В этом случае пришедшее от обгоняющего компьютера сообщение может быть помечено, как сообщение за следующий такт, до которого наш компьютер еще не добрался. И тогда наш компьютер должен сделать следующее… он просто сохранит это сообщение в свой собственный буфер NET_BUFFER next_message, и пока временно не будет больше выполнять никаких действий по этому поводу, но он будет знать, что сообщение за следующий сетевой такт от такого-то игрока уже получено. И когда начнется этот следующий сетевой такт, то первым делом наш компьютер возмёт эти команды из собственного буфера и тут же передаст их в игру через virtual void GGame::SetPlayerNetMessage(unsigned int sender, MEM_DATA& message). Это очень важный момент который желательно понимать для построения сетевого взаимодействия в аналогичных играх.
Также нужно понимать, что на 2 такта обгон уже невозможен, так как сработает процесс "ожидание других игроков", поэтому максимальное опережение может быть только на 1 сетевой такт.
Но чтобы принять команды, нужно для начала, чтобы кто-то их отправил. Сетевой клиент не должен знать ничего о специфики команд, используемых в игре, поэтому он просто вызывает функцию virtual MEM_DATA GGame::GetPlayerNetMessage(), которая возвращает ему готовый буфер с командами в виде MEM_DATA. Полученный буфер отправляется одновременно и себе всем другим игрокам игрокам.
MEM_DATA message=owner->game->GetPlayerNetMessage();
GNetSocket* socket=owner->GetMainSocket();
owner->game->SetPlayerNetMessage(socket->player_id, message); // копируем сообщение в собственный массив, как будто оно пришло по сети
int k_receiver=owner->RefreshReceiverList();
owner->m_receiver[k_receiver]=takt; // добавляем в сообщение номер сетевого такта, к которому относится сообщение
NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_GAME, message.length, message.data, k_receiver, owner->m_receiver, 1);
socket->SendMessage(result); // отправляем сообщение с командами всем другим игрокам
Сетевой такт контролирует игра, и она возвращает его клиенту через функцию virtual int GGame::GetNetTakt(). Однако клиент контролирует завершение сетевого такта — это момент, когда получены все команды от всех игроков. Клиент сразу сообщает об этом игре, вызывая virtual bool GGame::OnNextNetTakt(). Эта функция, в моем случае, проверяет на рассинхронизацию сети и возвращает true, если всё в порядке. Если вернется false, то сетевой клиент автоматически начнет процесс исправления рассинхронизации, для чего хост выполнит запись всех данных в файл и передаст этот файл всем другим игрокам, а другие игроки прочитают это файл и продолжат игру с этими полученными данными. Практически, хост сделает Save, а остальные игроки — Load. Контроль за рассинхронизацией сети я выполняю через подсчет суммы случайных чисел, выработанных за один сетевой такт. Случайные числа вырабатываются постоянно и на всех компьютерах они должны быть одинаковы, иначе это признак того, что сеть расинхронизировалась.
Если virtual bool GGame::OnNextNetTakt() возвращает true, то клиент для себя отмечает этот факт в переменной on_next_net_takt — это для него означает, что сетевой такт завершен. Игра же должна в своем основном цикле периодически вызывать функцию клиента bool GNetClient::IsNextNetTakt(){return on_next_net_takt;} и когда возвращается true, игра увеличивает сетевой такт на 1 и выполняет все команды, полученные по сети за прошлый сетевой такт, для каждого игрока. Затем массивы команд очищаются и всё начинается по новой, но уже с увеличенным значением сетевого такта.
Команды, которые игрок вводит через мышь и клавиатуру, попадают в сеть далеко не сразу. На деле происходит так, что в текущий сетевой такт в сеть передаются команды, которые были собраны за прошлый сетевой такт, а в это время собираются новые команды от мыши и клавиатуры. Т.е. происходит задержка в реакции на 1 сетевой такт. Это задержка называется Латентностью сети. Однако нет никакого смысла делать так, чтобы сетевые такты совпадали с тактами игры. Например, если игра обновляется 60 раз в секунду, то вполне можно сделать так, чтобы одному сетевому такту соответствовало 10 игровых. Вряд ли пользователя будет сильно раздражать задержка реакции в 1/6 секунды.
Шифрование трафика
Должен признать, что я не силен в теме защиты данных и хочу напоследок лишь обратить на неё внимание. К серверу не обязательно подключаться из игры, как на это расчитывает разработчик, т.е. практически, подключится можно с любой программы, обладающей возможностями для установки соединения с произвольным IP-адресом и портом. Далее можно начать отправлять на сервер всё что угодно. Сервер должен стараться как минимум проверять сообщения не корректность, иначе он просто схлопнется, получив порцию «бреда» и запутавшись в ней.
Также обычно сообщения с обоих сторон подвергаются шифровке.
Я не буду особо рассуждать на эту тему, скажу лишь, что минимальное шифрование трафика я использовал и у себя. Не думаю, что мою защиту сложно взломать, так как на данном этапе у меня нет ни желания, ни сил этим заниматься. Надеюсь, что пока меня будет защищать малая известность моего проекта, а там видно будет…
Об одной ошибке, которая существует в Windows уже очень давно
В первой части статьи я описал сетевую игру в RTS только в виде общей идеи. Но там я указал на самую большую проблему, которую таит в себе сеть в RTS — необходимость иметь полностью одинаковые вычисления на всех компьютерах. Если какой-то компьютер начнет делать хоть что-то чуть-чуть не так, то через пару минут у вас на разных компьютерах всё происходящее будет очень сильно различаться. А игра просто войдет в ступор, когда какой-то игрок попробует управлять на своем компьютере юнитом, который на другом компьютере уже давно «погиб в перестрелке». Подобные ошибки я считаю самыми страшными из тех, которые я видел, так как ловить такие баги логикой, практически, невозможно. Причины таких ошибок обычно какая-нибудь несущественная мелочь типа «забыл заново проинициализировать переменную при повторном запуске сетевой игры». И в результате эта переменная гарантированно развалит синхронизацию сети, причем очень задолго до того, как сама игра наконец схлопнется.
Если кому интересны мои рассуждения на эту тему, то обратитесь к первой части статьи. Сейчас же я хочу рассказать об одном, на мой взгляд, очень пакостном глюке, который присутсвует в Windows с незапамятных времен. Он гарантированно плодит ошибки в вычислениях с плавающей точкой, которые в свою очередь когда-то убивали мне сеть.
Обнаружил я эту проблему предположительно в 2003-2004 году еще на Windows 98, оттуда она благополучно перекочевала в Windows XP, а недавно я обнаружил, что и в Windows 8 ничего не изменилось.
Основная суть ошибки в том, что соответствующие функции Windows меняют (и не возвращают назад) контрольное слово FPU. И, естественно, про такое их поведение нигде в документуции не упоминается.
Вот мой старый код, который доказывает существование проблемы на Windows XP. На Windows 8 я его не пробовал, но и на Windows 8 я тоже «влетел в ситуацию», когда моя неплохо отлаженная сетевая игра начала вдруг работать безобразно без видимых причин. Оказалось, что я случайно убрал кусочек кода, который компенсирует эту проблему.
Итак пример функции:
int Error1()
{
double step=66.666664123535156;
double start_position_interpolation=0;
double position_interpolation=199.99998474121094;
double vdiscret=(position_interpolation-start_position_interpolation)/step;
int discret=(int)vdiscret;
return discret;
}
Числа, конечно, странные, но зато на них проявляется ошибка.
Если вызвать функцию Error1() и пройтись по ней отладчиком, то в результате в переменную discret попадет число 2. А теперь сделаем так:
int result=Error1(); // result=2
ok=direct_3d->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd,
D3DCREATE_HARDWARE_VERTEXPROCESSING,
&d3d9pp, device_3d );
result=Error1(); // а теперь уже result=3
Т.е. если между вызовами функции Error1() вклинить функцию DirectX-а, которая создает устройство DirectX, то второй вызов функции даст неожиданный результат discret=3. Так получается из-за того, что в первый раз vdiscret будет равен 2.99999......., а во второй раз уже 3. Разница на деле минимальна, но так как у меня в игре применяются переменные типа double, то этого вполне достаточно, чтобы убить всю сетевую игру. И кроме того, без знания причин там и починить-то ничего невозможно, ведь формально сам код верный, просто где-то какой-то флаг состояния процессора не вернулся в нужное значение.
В Windows 98 эта проблема проявлялась еще яростнее, чем в Windows XP. Там этот глюк возникал просто при попытка перечислить имеющиеся разрешения монитора, причем с помощью WinAPI, т.е. даже без DirectX. В Windows 8 я не исследовал этот вопрос, так как я уже заранее знал, что с этим делать.
Мне известны 2 решения, которые «лечат» эту напасть. Когда-то я просто создавал отдельный поток, переключал в нем разрешение экрана и потом просто убивал поток вместе с ошибкой. На основной поток эта проблема в данном случае не влияла.
Второй способ более прост.
unsigned int status=_controlfp(0,0);
// Переключаем разрешение экрана
// ...
// ...
// ...
_controlfp(status,_MCW_DN | _MCW_IC | _MCW_RC | _MCW_PC);
Это убивает глюк, который появляется после переключения разрешения экрана, и математика продолжает работать правильно.
Текущее состояние игры или релиз
Я решил, что игра уже вполне готова к релизу. Да, возможно, что придется подкорректировать баланс или исправить какие-то мелкие ошибки, но, по сути, игру пора выпускать. В любом случае, заниматься дальнейшим совершенствованием я вижу смысл только в случае, если будут постоянные игроки.
Недавно я, с большим удивлением обнаружил, что помимо всем известных доменов типа com, org, net и т.д, на свете существует и домен land. А так как моя игра на английском называется Onimod land, то я незамедлительно занял под игру домен onimod.land, так что теперь у игры, как и когда-то в прошлом, есть свой персональный сайт — onimod.land.
До выхода на Steam-е, думаю, дело дойдет чуть позже, а пока я выпускаю игру через свой сайт. Желающие поддержать мой проект материально, смогут это сделать на сайте с игрой. Однако я сам живу в России и понимаю, что у людей здесь имеются куда более насущные статьи расходов, чем покупка софта. Поэтому, если игра вам понравилась, а поддержать меня материально у вас нет финансовой возможности, то можете попросить у меня ключ бесплатно, используя форму обратной связи на сайте. Просьба не лениться представляться, а то письма типа «дай ключ» от wertwq@mail.ru вызывают у меня в основном негативные эмоции.
Игра стала коммерческой и, вероятно, скоро я узнаю, нужна она кому-то кроме меня или нет.
Пожалуй, на этой филосовской ноте я и завершу своё повествование. Благодарю всех за недюжую силу воли, проявленную при чтении данной статьи, а также за снисходительное отношение к моему «литературному дарованию».
С уважением, Алексей Седов (он же Odin_KG)
P.S.
У меня есть желание перевести статьи об игре на английский, по крайней мере, первую часть и алгоритм поиска пути.
Если у кого-то есть хорошие знания английского языка, хоть какое-то понимание того, о чем я пытаюсь говорить, и желание помочь мне в этом деле, то я буду очень рад. Я пробовал как-то нанять переводчика, но он мне такую грамматически правильную смысловую ахинею выдал, что делать еще одну аналогичную попытку у меня особого желания нет.
Эту просьбу я удалю из статьи, если кто-то откликнется и реально возьмется за дело.
Начало статьи: Воскрешение игры
Продолжение статьи: GUI
Автор: Седов Алексей Павлович