Сетевой код.
Автор: CyberDemon
Написание правильного и корректного сетевого кода - задача достаточно простая. Надо только немного подучить и «попробовать» Winsock. Но необходимо так же писать и быстрый код, иначе удовольствия от игры по сети (а, точнее, по модему) особого не получишь. Достаточно вспомнить оригинальный Quake. Если кто пробовал играть в эту игрушку по модему, думаю, не один десяток нелитературных выражений отмочил :).
В данном случае идет речь о соединении клиент-сервер, когда клиент посылает серверу данные о действиях пользователя, а сервер, обработав всех клиентов, рассылает им данные о состоянии игры.
В этом способе связи есть свои преимущества и недостатки.
Преимущества — затруднение cheating-а, клиенту не обязательно иметь навороченную конфигурацию компьютера, ведь ему надо только принять серверные данные о том, что изменилось в игровом мире и что стОит изобразить на экране. К тому же, нет специальной синхронизации для того, чтобы клиенты имели идентичные состояния игрового мира (Например, Doom не был построен по технологии клиент-сервер, каждый компьютер рассчитывал сам различные параметры игры на основе генератора псевдослучайных чисел, и приходилось все это синхронизировать).
Зато, есть очень существенные недостатки, которые практически незаметны при игре по локальной сети, но очень сильно проявляются при игре по модему. Самый главный недостаток - Ping, точнее не он сам ;), а его высокое значение. Ping - это время, за которое пакет от клиента достигает сервера, плюс время ответа сервера, плюс время прохождения пакета от сервера к клиенту. В локальной сети его значение обычно лежит в пределах от 5 до 30 мсек. При игре же по модему, значение ping может быть от 50 (ну это просто идеальные условия) и до бесконечности. Если предположить, что модемная связь будет приемлемой, значение Ping будет зависеть только от "кривости" сетевого кода и от состояния игрового мира (изменения различных параметров игровых элементов).
Таким образом, оптимизация сетевого кода, а точнее оптимизация размера передаваемых данных - очень серьезная и сложная задача.
Но, сначала, конечно же, мы рассмотрим, как все это вообще работает, а уж потом перейдем к оптимизации.
Итак, имеем: соединение посредством Winsock (можно, конечно, использовать DirectPlay, но только, чем меньше у нас посредников, тем быстрее все будет работать, не так ли ?), протокол UDP (TCP соединение в нашем случае использовать можно только в локальной сети, потому что одно из преимуществ протокола TCP - 100%-ая доставка пакетов — в данном случае превращается в огромный недостаток, ведь если пакет не доставлен успешно, его будут посылать еще раз, в состояние игры уже изменилось !).
Примеры построены с использованием открытого исходного кода Quake2. (В качестве отступления от темы, хочу сказать личное мнение об этом коде - код написан достаточно "прямо" и оптимально, безглючно и вообще, легко читается). Было бы также неплохо, если Вы будете периодически заглядывать в документацию к winsock. Полезно сначала прочитать теорию, а потом рассмотреть это на практике.
Инициализация Winsock:
void Net_Init() { // запрашиваем версию Winsock 1.1 WORD wVersionRequested = MAKEWORD( 1,1); if ( WSAStartup( wVersionRequested, &winsockdata)) { printf( "Winsock initialization failedn"); return; } printf( "Winsock initialized\n"); }
После успешного выполнения этих действий, библиотека winsock готова Вас обслужить :).
Теперь напишем метод инициализации сокетов.
// net_interface - имя компьютера, обычно устанавливается в localhost, // но есть некоторые случаи, когда это не так, например, когда компьютер // находится сразу в двух сетях и имеет, соответственно, // несколько имен (ip адресов) // порт - значение порта, на который "вешается" socket, // если PORT_ANY (собственная константа, равна, например, -1), // то socket создается с произвольным портом int Net_CreateIPSocket(char *net_interface, int port) { int sock; unsigned long flag = true; int i = 1; sockaddr_in address; // создаем UDP socket sock = socket( PF_INET, SOCK_DGRAM, IPPROTO_UDP); if ( sock == -1) { printf( "Error creating UDP socket\n"); return 0; } // устанавливаем параметр nonblocking для socket, что означает, что если на // входе нет данных, то метод чтения не будет ждать их появления if ( ioctlsocket ( sock, FIONBIO, &flag) == -1) { printf ( "Error setting nonblocking socket\n"); return 0; } // настраиваем socket так, чтобы была возможность посылать и принимать broadcast // сообщения, то есть, сообщения, направленные всем сетевым клиентам в текущей // локальной сети if ( setsockopt( sock, SOL_SOCKET, SO_BROADCAST, ( char *)&i, sizeof( i)) == -1) { printf ( "Error setting broadcast socket\n"); return 0; } // устанавливаем произвольное значение (по выбору winsock) порта if ( !net_interface || !net_interface[0] || !stricmp( net_interface, "localhost")) address.sin_addr.s_addr = INADDR_ANY; if ( port == PORT_ANY) address.sin_port = 0; else address.sin_port = htons( ( short)port); address.sin_family = AF_INET; // "прикрепляем" socket к порту if( bind ( sock, ( const struct sockaddr*)&address, sizeof( address)) == -1) { printf ( "Error bindind socket\n"); closesocket ( sock); return 0; } return sock; }
Сокетов может быть не более 2-ух. Один для сервера, второй - для клиента (для выделенного сервера он не нужен).
Можно завести 2 переменные, server_socket и client_socket, а можно, как сделано в Quake2, создать массив из 2-ух элементов и заголовочном файле описать enumerator:
enum net_source_t { NS_CLIENT = 0, NS_SERVER = 1 };
А в основном файле опишем массив
static int ip_sockets[] = {0,0}; // IP server & client sockets
Напишем общий метод инициализации сокетов, который будет вызываться при самой первой инициализации клиента или сервера.
void Net_PrepareNetwork(bool multiplayer) { static bool old_multiplayer = false; if ( old_multiplayer == multiplayer) return; old_multiplayer = multiplayer; if ( !multiplayer) { // удаляем сокеты в случае single game, нам в этом случае сокеты // вообще не нужны, общение ведется через память if ( ip_sockets[NS_SERVER]) { closesocket( ip_sockets[NS_SERVER]); ip_sockets[NS_SERVER] = 0; } if ( ip_sockets[NS_CLIENT]) { closesocket( ip_sockets[NS_CLIENT]); ip_sockets[NS_CLIENT] = 0; } } else { // open sockets Net_PrepareIP( ); } }
В методе используется другой более конкретизированный метод инициализации сокетов по протоколу IP. Так же можно сделать инициализацию для протокола IPX.
// здесь, // sv_serverport - внешняя переменная, содержит порт сервера // sv_dedicated - переменная, показывающая, что мы создаем выделенный сервер // cl_clientport - переменная, содержит порт клиента // (в принципе, это значение не так уж и важно) void Net_PrepareIP() { int port; // open server socket if ( !ip_sockets[NS_SERVER]) { port = sv_serverport.GetValueInt( ); ip_sockets[NS_SERVER] = Net_CreateIPSocket( "localhost", port); if ( !ip_sockets[NS_SERVER] && sv_dedicated.GetValueInt( )) Error( "Couldn't create dedicated server socket"); } if ( sv_dedicated.GetValueInt( )) // no client socket for dedicated servers return; // open client socket if ( !ip_sockets[NS_CLIENT]) { port = cl_clientport.GetValueInt( ); ip_sockets[NS_CLIENT] = Net_CreateIPSocket( "localhost", port); if ( !ip_sockets[NS_CLIENT]) // port is busy ip_sockets[NS_CLIENT] = Net_CreateIPSocket( "localhost", PORT_ANY); } }
Ну вот мы и написали методы инициализации сети. Осталось совсем немного - написать методы для посылки/приема пакетов и методы работы с локальным клиентом.
Для этих методов необходимо описание некоторых структур.
// тип сетевого адреса enum net_addr_type_t { NA_LOOPBACK = 0, // локальный клиент NA_BROADCAST = 1, // broadcast сообщение, посылка всем компьютерам в сети NA_IP = 2 // IP протокол }; // сетевой адрес struct netadr_t { net_addr_type_t type; byte ip[4]; // IP адрес состоит из 4 байтов unsigned short port; };
Метод посылки пакета:
void Net_SendPacket(net_source_t src, int size, void *data, netadr_t *dest) { sockaddr_in addr; // если локальный клиент, то вызываем специальный метод if ( dest->type == NA_LOOPBACK ) { NET_SendLoopPacket ( src, size, data, dest); return; } // проверка на корректность адреса и сокета if ( dest->type == NA_BROADCAST || dest->type == NA_IP) { if ( !ip_sockets[src]) return; } else Error( "Net_SendPacket: bad socket type"); // переводим "наш" адрес в socket-compatible ;) NetadrToSockadr( dest, ( sockaddr*)&addr); // пытаем послать данные int res = sendto( ip_sockets[src], ( char*)data, size, 0, ( sockaddr*)&addr, sizeof( addr)); if ( res == -1) { int error = WSAGetLastError( ); // вообще то при посылке через non-blocking socket такой ошибки не бывает... if ( error == WSAEWOULDBLOCK) return; // некоторые PPP соединения не позволяют broadcast сообщения if ( error == WSAEADDRNOTAVAIL && dest->type == NA_BROADCAST) return; // выделенный сервер может "проглотить" ошибку... if ( sv_dedicated.GetValueInt( )) { printf( "Net_SendPacket error\n"); } else { // ошибка "адрес не существует" - это не очень страшно if ( error == WSAEADDRNOTAVAIL) C_Printf( "Net_SendPacket error: address not available\n"); else Error( "Net_SendPacket ERROR\n"); } } }
Метод приема пакета:
// класс buffer будет описан позже bool Net_GetPacket(net_source_t sock, netadr_t *src, buffer *net_message) { sockaddr from; // пытаем прочитать данные локального клиента if ( NET_GetLoopPacket ( sock, src, net_message)) return true; // проверка корректности сокета if ( !ip_sockets[sock]) return false; // читаем пакет int fromlen = sizeof( from); int res = recvfrom( ip_sockets[sock], ( char*)net_message->data, net_message->maxsize, 0, &from, &fromlen ); // переводим адрес отправителя в "наш" формат SockadrToNetadr( &from, src); if ( res == -1) { int error = WSAGetLastError( ); // сокет пока занят, if ( error == WSAEWOULDBLOCK) return false; // слишком большой пакет if ( error == WSAEMSGSIZE || res == net_message->maxsize) { printf( "Warning: Too big packet \n"); return false; } // выделенный сервер "глотает" ошибку if ( sv_dedicated.GetValueInt( )) printf( "Net_GetPacket: Error reading packet\n"); else Error( "Net_GetPacket: Error reading packet"); } net_message->cursize = res; return true; }
Описание класса buffer:
Этот класс предназначен для более удобной работы с массивом данных различных типов.
#define MAX_UDP_PACKET 2048 class buffer { public: byte *data; int cursize; // buffer size int maxsize; // buffer max size buffer() { maxsize = MAX_UDP_PACKET; cursize = 0; data = new byte[MAX_UDP_PACKET]; } buffer( int size) { cursize = 0; maxsize = size; data = new byte[size]; } ~buffer( ) { if ( data) delete data; } // остальные методы будут описаны позже };
Осталось только разобраться со способом передачи данных между локальными клиентом и сервером.
Сделаем имитацию работы winsock. В winsock мы можем послать несколько пакетов другому компьютеру, пока он их будет принимать, то есть, надо создать некоторую очередь для пакетов. В Quake2 это реализовано достаточно простым и оригинальным способом - через "закольцованный" массив. Можно это сделать с использованием STL + класс buffer. Но как я уже сказал ранее, код Quake2 написан достаточно оптимально, к тому же, нам не нужны методы класса buffer, нам нужен просто массив и переменная его размера, поэтому для примера используем оригинальный код.
// размер очереди, подбирается экспериментально - так, чтобы локальные // пакеты не "терялись" #define MAX_LOOPBACK 4 typedef struct { byte data[MAX_UDP_PACKET]; int datalen; } loopmsg_t; typedef struct { loopmsg_t msgs[MAX_LOOPBACK]; // указатель на начало очереди для send и на конец для get int get, send; } loopback_t; static loopback_t loopbacks[2]; // для сервера и клиента
Ну а теперь сами методы:
void NET_SendLoopPacket (net_source_t sock, int length, void *data, netadr_t *to) { int i; loopback_t *loop; loop = &loopbacks[sock^1]; // выбор сервер/клиент // вот так реализован "закольцованный" массив i = loop->send & ( MAX_LOOPBACK-1); loop->send++; memcpy ( loop->msgs[i].data, data, length); loop->msgs[i].datalen = length; } bool NET_GetLoopPacket ( net_source_t sock, netadr_t *net_from, buffer *net_message) { int i; loopback_t *loop; loop = &loopbacks[sock]; // проверка не переполнение локального буфера if ( loop->send - loop->get > MAX_LOOPBACK) loop->get = loop->send - MAX_LOOPBACK; // а может, мы уже все прочитали ? :) if ( loop->get >= loop->send) return false; i = loop->get & ( MAX_LOOPBACK-1); loop->get++; memcpy ( net_message->data, loop->msgs[i].data, loop->msgs[i].datalen); net_message->cursize = loop->msgs[i].datalen; memset ( net_from, 0, sizeof( *net_from)); net_from->type = NA_LOOPBACK; return true; }
Ну, и в заключении, методы перевода "наших" адресов в winsock-compatible и обратно.
void NetadrToSockadr (netadr_t *a, struct sockaddr *s) { memset ( s, 0, sizeof( *s)); if ( a->type == NA_BROADCAST) { ( ( struct sockaddr_in *)s)->sin_family = AF_INET; ( ( struct sockaddr_in *)s)->sin_port = a->port; ( ( struct sockaddr_in *)s)->sin_addr.s_addr = INADDR_BROADCAST; } else if ( a->type == NA_IP) { ( ( struct sockaddr_in *)s)->sin_family = AF_INET; ( ( struct sockaddr_in *)s)->sin_addr.s_addr = *( int *)&a->ip; ( ( struct sockaddr_in *)s)->sin_port = a->port; } } void SockadrToNetadr ( struct sockaddr *s, netadr_t *a) { if ( s->sa_family == AF_INET) { a->type = NA_IP; *( int *)&a->ip = ( ( struct sockaddr_in *)s)->sin_addr.s_addr; a->port = ( ( struct sockaddr_in *)s)->sin_port; } }
Эта была первая часть статьи. Здесь был описан низкоуровневый драйвер для работы с сетью. Напрямую он практически не используется движком. Во второй части будет описан класс, с которым непосредственно будет работать движок. В нем будет реализовано несколько интересных вещей, самая главная из которых - возможность 100% доставки "важных" данных (reliable data). Но это будет позже.
15 февраля 2002
Комментарии [2]