Программирование игр, создание игрового движка, OpenGL, DirectX, физика, форум
GameDev.ru / Программирование / Статьи / Сетевой код.

Сетевой код.

Автор:

Написание правильного и корректного сетевого кода - задача достаточно простая. Надо только немного подучить и «попробовать» 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

#Winsock

2001—2018 © GameDev.ru — Разработка игр