Всем привет, это снова я, и сегодня у меня с собой вторая часть урока по GMS: Networking. Сегодня мы охватим сразу 2 очень важных момента - работа с UDP соединением и сглаживание. В этом уроке мы будем модифицировать проект, который мы рассматривали в первой части. Если говорить конкретнее, то мы его несколько изменим, и координаты у нас будут передаваться через UDP. Итак, начнем.
Часть 1: Подготовка и предисловие.
Ну тут нечего особо написать, загружаем наш прошлый проект в 2 окна, запасаемся пивом и чипсами, и настраиваемся на работу. Прежде чем перейдем непосредственно к редактированию, пару слов о работе UDP.
У него есть несколько нюансов, которые довольно сильно отличают работу с UDP протоколом от работы с TCP.
Во первых - UDP НЕ нужно предварительно устанавливать соединение. С одной стороны это хорошо, но с другой это порождает проблемы, о которых я расскажу в заключении статьи.
Во вторых - UDP не гарантирует доставку пакетов. Пакет отправляется один раз, после чего или принимается отправителем, или навсегда остается блуждать по просторам интернетов. Кроме того, пакет а, отправленный раньше, вполне может прийти много позже чем пакет б, который мы отправляли например через несколько секунд после а. Борьба со всем этим ложится уже непосредcтвенно на плечи сетевого разработчика, но вообще эти проблемы легко решаются и значительных проблем при хорошей разработке не вызывают.
Ну и наконец 3 момент - каждое 2-хстороннее UDP соединение занимает в сумме 2 порта.(TCP занимает 1). Проще говоря, если мы используем TCP, и сервер у нас находится на 10000 порту, то все клиенты подключаются и работают через него. Если же мы добавим сюда UDP соединение в качестве дополнительного канала, то мы дополнительно занимаем: 10001 и 10002 порты для общения с клиентом 1; 10003 и 10004 порты для общения с клиентом 2;
Ну и т.д. По хорошему, при генерации номеров портов, нужно обязательно проверять их на доступность, и только потом ими пользоваться, но я не буду этим заниматься в примере, чтобы не нагружать и без того непростой урок лишними подробностями.
Кроме того, прошу меня извинить за дезинформацию в первом уроке насчет функций networking_create_socket и networking_create_server. На самом деле это я просто нуб и делал какие то кривые тесты, эти функции возвращают значение < 0 в случае если порт занят или невозможно создать сокет. Но опять же, в уроке я это описывать не буду. можете сами добавить эти проверки в нужных местах в качестве первого упражнения из домашнего задания.
Часть 2: Ковыряем код.
Итак, начнем ковырять код. По сути, у нас поменяется лишь механика подключения и обработки координат на сервере, а остальные моменты мы практически не затронем. Начнем мы с изменения серверной части. Идем в сервер, в объект o_server, в networking. Первое что мы делаем - это полностью комментируем блок под условием
Код
if type = network_type_data
Не удаляем его! Он будет нам еще нужен, но пока что он нам незачем.
Символами // я обозначил новые строки, которых не было в старом проекте. начем разбор полетов. Во первых, мы вычисляем новые порт для UDP соединения для клиента(вообще то клиент должен делать это сам, но в нашем случае это было бы ненужное усложнение ,потому я его опустил). Это строка
Код
players[sock].udp_port = port + sock;
Для первого клиента sock = 1 (нулевой сокет в нашем сервере - это сокет прослушки TCP самого сервера), и имеем в результате udp_port = 10002. Через этот порт мы будем ОТСЫЛАТЬ сообщения нашему клиенту. Далее, для отсылки нам понадобится сокет, его мы создаем следующей строкой и записываем в udp_socket. Проницательный читатель спросит - а зачем же мы все это записываем в объект-эквивалент игрока на сервере? А затем, отвечу я, что каждый объект-игрок САМ будет обрабатывать только ему принадлежащий поток! А определять он свои сообщения будет с помощью идентификатора сокета server_udp, который мы создаем следующей строкой. Именно server_udp будет являться идентификатором уникальности потока конкретного клиента, и сравнивать мы будем его с полученны eventid. Сейчас выглядит слегка запутанно, но на самом деле все не так плохо, когда перейдем к программированию приемки координат, все сразу станет ясно.
Последние 6 строк формируют для новоподключившегося клиента данные о том, как ему соединяться с сервером по UDP. Как мы помним по нашей структуре пакета, 1 идет отправитель(отправитель - это не сервер, а тот кто прислал обрабатываемый пакет серверу. В нашем случае это сам игрок которому мы готовим пакет), 2 идет уникальный идентификатор типа сообщения(в нашем протоколе 3 - это будет команда сервера создать UDP соединение), ну и 3 и 4 это порты, строка 3 это порт который будет слушать клиент, и строка 4 - порт, на который клиент шлет данные. Когда пакет готов, отправляем его игроку.
Теперь нужно принять этот пакет у нас на клиенте. Открываем окно клиента, объект o_netw, networking. В конец конструкции switch после второго кейса, создаем case 3:
Вот и чтение нашего пакета с идентификатором 3. Теперь уже мы можем проверить что у нас все работает, воспользуемся для этого обычным сообщением, добавим в наш новый кейс(перед строчкой break) следующее:
Если все сделали правильно, то выскочит сообщение с портами, номера которых мы уже получили с сервера. Если что то идет не так, попробуйте поиграть с начальным портом для TCP соединения(Он должен быть одинаковым на сервере и клиенте!). Если и это не помогает - ищите ошибку. Если не работает даже приложенный пример - возможно стоит обновить GM studio.
Ну а я считаю что у вас все получилось, и мы движемся дальше. После того как вылезло сообщение, треугольник на сервере появился, но он не двигается - потому что мы закомментили кусок кода, отвечающий за прим пакетов с координатами. Нум нужно перенести его в другое место и немного адаптировать под UDP. Идем в окно сервера, объект o_player. Создаем ему networking ивент и записываем туда код:
Код
eventid = ds_map_find_value(async_load, "id"); type = ds_map_find_value(async_load, "type");
if type = network_type_data { msg_buff = ds_map_find_value(async_load, "buffer");
if !(udp_port = 0) && eventid = server_udp { if initiated = false { send_starting_info(); initiated = true; }
x = buffer_read(msg_buff, buffer_s16); y = buffer_read(msg_buff, buffer_s16); image_angle = buffer_read(msg_buff, buffer_s16);
Страшно? А вот и зря. Все очень просто. Первые 2 строки мы уже знаем наизусть с прошлого урока. Тип нужен нам чтобы к нам не попадали запросы типа коннект-дисконнект. После этого мы получаем ссылку на наш буффер сообщения, содержащий координаты. Его мы разрабатывали еще в прошлом уроке, вкратце напомню лишь что его структура - (x, y, image_angle). Если мы уже вычислили порт(его мы вычисляем в момент коннекта по TCP) и eventid соответствует прослушивающему сокету(вспоминаем тираду про server_udp в начале) тогда мы определяем, инициирован ли уже наш объект или нет(то есть это выполняется когда приходит 1 UDP пакет и больше не выполняется никогда), если нет - отсылаем ему необходимые начальные данные о других игроках, считываем данные пришедшего сообщения, после чего отправляем координаты этого игрока всем остальным игрокам через скрипт send_to_all_except_one_udp(sock). В качестве аргумента мы используем sock потому что он является нашим главным идентификатором конкретного игрока в системме в целом, сам же листинг скрипта вот:
Код
i = 0; while (i<= global.n_max) { if i != argument0 { if o_server.players[i] != -1 { if o_server.players[i].initiated = true { network_send_udp(o_server.players[i].udp_socket, o_server.players[i].ip, o_server.players[i].udp_port, buffer_send, buffer_tell(buffer_send) ); } } } i++; }
В принципе этот код не нуждается в комментариях, он по сути такой же как и аналогичный для TCP, с тем лишь различием что мы используем для отсыла функцию network_send_udp, и в качестве дополнительных параметров по сравнению с TCP функцией у нее есть порт и ip-адрес, которые мы предусмотрительно записали в наш объект игрока.
Теперь на секунду заглянем к нему же в create event и допишем несколько строчек:
Еще нужно чуть чуть подредактировать скрипт send_starting_info. Это связано с тем что он теперь вызывается из другого объекта, так что изменения чисто косметические. Вот полный код нового тела скрипта
Код
i = 0; while (i<= global.n_max) { if o_server.players[i] != -1 && i != sock { buffer_seek(buffer_send, buffer_seek_start, 0); buffer_write(buffer_send, buffer_s16, i ); buffer_write(buffer_send, buffer_s16, 0 ); network_send_packet( sock, buffer_send, buffer_tell(buffer_send) ); } i++; }
Теперь осталось сделать только одно - сказать клиенту чтобы он слал наши данные не по TCP каналу, а по UDP. Так что идем в окно клиента, открываем o_netw: step и меняем строчку
И все! Теперь клиент шлет данные не по TCP, а по UDP протоколу. Все готово! Можно делать установщик и пробовать подключать несколько клиентов, если все сделано правильно - то все будет работать.
Часть 3: Пример сглаживания движения.
Итак, мы все сделали правильно, и теперь у нас клиенты двигаются используя UDP протокол. Старательный начинающий сетевой игродел справедливо начнет негодудеть - но ничего же не изменилось, ты нас надул и потратил столько нашего времени, а клиенты все также нереально дергаются! А вот и нет.
Что в случае с TCP, что в случае с UDP, все равно необходимо добавлять сглаживание движения в клиентах. Координаты в любом случае обновляются достаточно редко. Мы конечно можем в каждом клиенте отправлять данные каждый шаг, и в случае с UDP это действительно приведет к неплохим результатам, но НИКОГДА так не делайте, особенно для мобильных клиентов. Это вмиг забьет канал. Координаты должны обновляться каждые 2-3 шага для клиента со скоростью обновления в 30 фпс, т.е. раз 10 в секунду. Этого более чем достаточно.
Начнем реализовывать простейшее сглаживание. Первое что нам нужно сделать, это удалить из клиента и сервера передачу данных об угле поворота игроков. Теперь мы будем считать их самостоятельно. Сначала удалим их с сервера, это находится в объекте o_player в networking event, строчки
Теперь идем в клиент. Все дальнейшие изменения мы будем совершать только там. Первое что мы сделаем, это так же удалим некоторые строчки. В объекте o_ntew, networking ищем строку
Больше image_angle у нас не будет передаваться и приниматься клиентами. Можете запустить и убедиться что все нормально, клиенты по прежнему двигаются, но не поворачиваются.
Теперь делаем само сглаживание. для начала, нам нужно слегка переделать алгоритм чтения пакета с координатами. Он больше не должен прямо устанавливать координаты для других клиентов, а должен лишь давать им опорные точки для движения. Так что идем в окно клиента, объект o_netw, networking и заменяем блок case 1. Он должен выглядеть так:
Поясню. Объекты в массиве players - типа obj_othplayers. Мы еще не редактировали его, но сейчас будем. Опираться мы будем лишь на 1 контрольную новопришедгую точку, и именно ее координаты мы пишем в xx и yy нашему игроку. Также мы помечаем для него флаг new_pack в истину, что означает что нам нужно сделать перерасчет данных внутри игрока. Теперь открываем собственно объект obj_player и создаем ему create:
Вот наш список используемых переменных для сглаживания. тут мы видем и xx, и yy, и new_pack, который false пока мы не получим первый пакет. Осталась лишь самая малость - научить наши объекты работать с приходимыми данными!
Для этого создаем объекту obj_othplayers ивент draw, и пишем в него следующий страшный листинг:
Код
draw_self(); if new_pack && last_delay != 0//пришел новый пакет, пересчитываем скорость и направление { //Обязательна проверка на last_delay != 0, поскольку нам на него делить. if xx_prev != xx || yy_prev != yy// Если новый пакет такой же как и прошлый, значит игрок стоит на месте, останавливаемся. { distance = point_distance(x, y, xx, yy);//вычисляем дистанцию до новых пришедших координат direction = point_direction(x, y, xx, yy);//направление из текущих координат в новые. if distance < max_range //Если расстояние достаточно небольшое то выполняем сглаживание, иначе просто устанавливаем координаты. { // last_delay показывает задержку в шагах между прошлым пакетом и текущим, соответственно на нее и делим дистанцию speed = distance/last_delay; } else//Если игрок улетел далеко то просто приравниваем его координаты к новым(такое может быть при лаге например) { x = xx; y = yy; speed = 0; } } else//Если предыдущий пакет такой же как и новопришедший, то просто стоим. { x = xx; y = yy; speed = 0; } new_pack = false; //Сбрасываем все значения для следующей итерации last_delay = 1; xx_prev = xx; yy_prev = yy; } else //Иначе просто увеличиваем задержку на единичку { last_delay++; } image_angle = direction;
Такие дела. Я вставил максимально подробные комментарии в сам код, чтобы не разводить демагогию здесь, и в принципе никаких невероятных вещей там нет, простейшие условия, функции и вычисления.
Теперь мы можем протестить наше сглаживание. Запускаем несколько клиентов и наслаждаемся результатом Готово!
Часть 4: Заключение.
Несколько слов в заключение урока.
Во первых, хочу еще раз повторить что я намеренно пропускаю лишние усложнения и проверки, чтобы не усложнять ими код. Для тех кто не знает основных сетевых принципов, даже то что описано тут поначалу будет крайне сложно для понимая, потому я стараюсь максимально облегчить урок.
Во вторых, сглаживание работает не идеально. Во первых, это связано с тем что сам алгоритм довольно простой и не учитывает некоторых нюансов. Во вторых, мы передаем только целые числа и не учитываем доли единиц координат ,которые учитывает клиент. Поэтому угол поворота при низких скоростях на клиентах может отличаться.
Третий пункт. Все наши старания по настройке UDP соединения падут прахом, если на их пути встанет роутер. Это связано с недоработкой GMS в версиях до релиза 1.3. Дело в том что клиент, как и сервер, использует функцию networking_create_server() для создания UDP-прослушки на определенном порту, но как мы знаем из первого урока по TCP, эта функция не работает если порт не проброшен в роутере. Потратив немало часов на лазание по форуму GMS, я узнал, что в 1.3 это исправили, и там все работает корректно, но я пока использую по ряду причин версию 1.2, в особенности из-за проблем с андроидом в 1.3, потому сам не могу проверить. Вдобавок, изменения, коснувшиеся UDP соединения, так же внесли некоторые нюансы в работу с UDP портом, так что чтобы протестировать это несколько строчек нашего проекта нужно будет переписать. Так что живем и верим в лучшее, а пока что - учимся
В-четвертых: на мобильниках UDP работает только в одну сторону - отправка на сервер. Прием мы можем осуществлять только через TCP, причина этого в принципе в том же, в чем и проблема с роутером на компьютере - корректно открыть порт для прослушки на мобильных девайсах, используя мобильный интернет, мы не можем! Хотя железо и ПО это сделать, в принципе, позволяют. Но даже односторонне UDP соединение может очень сильно упростить жизнь мобильным сетевым приложениям.
Ну и наконец пункт 5: Домашнее задание. Предлагаю вам несколько упражнение для закрепления результата. 1) Добавить проверки для всех функций создания сокетов. 2) Соответственно, сделать так чтобы клиент сам себе выбирал UDP порт и отсылал данные серверу. 3) Сделать систему никнеймов, чтобы все клиенты видели над объектами других игроков их ник.
На сим откланяюсь, спасибо за внимание, ваш Ghaarp(aka XDominator)
P.S. Обращение к модератору. Возможно, стоит закрепить темы, чтобы не потерялись? тем более что планирую написать еще как минимум 1 статью, с примерами базовых систем типа никнеймов, стрельбы, АИ, и пр. ? Ghaarp