Морской бой (Компьютерная игра)

Материал из Национальной библиотеки им. Н. Э. Баумана
Последнее изменение этой страницы: 14:48, 8 июня 2016.
Версия от 14:48, 8 июня 2016; Pavel Shagaev (обсуждение | вклад)

(разн.) ← Предыдущая | Текущая версия (разн.) | Следующая → (разн.)

Данная статья посвящена реализации сетевого взаимодействия в кроссплатформенной многопользовательской игре Морской бой. Используемые языки и фреймворки: C++, Qt.

Аннотация

В рамках данной работы рассмотрено применение TCP-протокола и языка программирования C++ в сочетании с библиотекой графического интерфейса Qt в контексте разработки кроссплатформенной сетевой игры “Морской бой”. Программа состоит из серверной и клиентской частей. Многопоточный сервер, реализованный с использованием низкоуровневых системных вызовов, отвечает за взаимодействие игроков, обмен сообщений между ними, а также восстановление начатой игровой сессии после сбоя. Программа-клиент имеет интуитивно понятный графический интерфейс с элементами реалистичной графики отображения элементов полей игроков. В рамках проекта разработан и реализован протокол взаимодействия сервера и клиента, включающий как передачу команд игры, так и произвольные сообщения в формате чата. Во время разработки программы были также были исследованы различные паттерны проектирования для комбинации различных стратегий автоматической расстановки кораблей. В статье будет дано описание функционала программы и реализация сетевого взаимодействия.

Описание приложения

Клиент

При работе с программой “Морской бой” пользователь может даже не знать о существовании сервера, который обеспечивает взаимодействие клиентов. С программой может играть самый обыкновенный человек, далекий от программирования и сетевых технологий. Приложение-клиент состоит из игровых полей противников, меню и кнопок управления. Сначала игрок расставляет корабли в специальном диалоге, доступном по кнопке “Расставить корабли”. Затем он может либо подключиться к игровому серверу, указав его адрес и порт, а также ник – имя, под которым данный игрок будет виден другим игрокам; либо начать игру с компьютером, выбрав при этом уровень сложности. На сложность влияет тактика расстановки кораблей и наличие в алгоритме игры нейросетевой модели по определению места удара машины. Если пользователь выбирает сетевой вариант игры, то ему необходимо подобрать себе соперника. Для этого предусмотрена функция отображения доступных игроков – кнопка “Свободные игроки”. Выбрав конкретного соперника из списка, пользователь может предложить ему сыграть с ним в “Морской бой”. При этом у второго игрока появится сообщение с предложением поиграть, на которое он может ответить как положительно, тогда начнется игра, так и отрицательно. Первым ходит игрок, который предложил игру. При наведении мыши на поле противника, ее курсор изменяется для осуществления выстрела.
Скриншот программы-клиента
В случае если будет подбит или убит корабль, игроку дается право следующего хода, если же выстрел оказался неудачным, право хода передается противнику, и курсор мыши снова переходит в свое обычное состояние. Когда все корабли игрока потоплены, он признается проигравшим, и выводится соответствующее сообщение. Во время игры игроки могут обмениваться сообщениями между собой в формате чата. Все параметры окна (ширина, высота, расположение, сцена боя) и последние введенные данные (логин, IP адрес, номер порта, уровень сложности) записываются в реестр и автоматически восстанавливаются при следующем запуске приложения. Благодаря применению кроссплатформенной библиотеки Qt версии 5.3 программу можно скомпилировать на всех современных операционных системах. Клиент тестировался на операционных системах Windows XP, 7, 8, 8.1, 10 и OpenSuse Linux 11.4.

Для достижения реалистичной графики использовался комбинированный подход, заключающийся в сочетании трехмерной графики стандарта OpenGl и двумерного инструментария QPainter. Трехмерное моделирование поверхности воды относится к сложным задачам компьютерной графики, поэтому в рамках данной работы была использована общедоступная OpenGl-реализация морской поверхности. С помощью QPainter можно рисовать графические двумерные примитивы, используя в качестве фона трехмерное изображение. Эффект волны достигается путем наложения нескольких текстур и шума, положение гребня меняется линейно по таймеру каждые 25 мс.

Сервер

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

Реализация сетевого взаимодействия

Подключение необходимых библиотек с учётом кроссплатформенности

Код сервера, отвечающий за установление клиентов, буден разобран в листингах ниже. Поскольку целью работы является создание кроссплатформенного сервера, а программные интерфейсы сетевых подсистем операционных систем семейств Windows и Unix немного различаются, то устранить это различие можно при помощи условной компиляции с использованием директив препроцессора. Подключение библиотек будет выглядеть следующем образом:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <map>
#include <iostream>
//#define WIN32   // Раскомментировать при компиляции под [[Windows]]
#ifndef WIN32

//заголовки для Unix
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <resolv.h>
#include <dlfcn.h>

#else
//заголовки для [[Windows]]
#define HAVE_STRUCT_TIMESPEC 1
#include <winsock2.h>

#endif

#include <sys/time.h>
#include <errno.h>
#include <string.h>
#include <string>
#include <pthread.h>

Основной рабочий цикл, функция main()

В следующем фрагменте описывается функция main(). Строки 3–9 отвечают за инициализацию, а строки 39–41 за освобождение занимаемых сетевых ресурсов сетевой подсистемы в системах Windows. Строки 10–20 отвечают стандартным настройкам создания сокета на стороне сервера. Бесконечный цикл, записанный в 21–38 отвечает за обработку клиентов. После поступления запроса на соединение от какого-либо клиента, создается отдельный поток, который будет его обслуживать (32-36). В качестве библиотеки по созданию и управлению потоков использовали библиотеку PThreads. Особо следует отметить строку 36. В ней происходит блокировка мьютекса. Параметр идентификатора клиента client передаётся в потоковую функцию Child как указатель. Может произойти такая ситуация: по каким-либо причинам потоковая функция не запустилась, а в это время accept приняла запрос на соединение от нового клиента. В этом случае теряется идентификатор предыдущего клиента. Чтобы устранить этот эффект, была введена критическая секция, которая начинается с момента создания потока и до момента, когда потоковая функция не сохранит идентификатор своего клиента в надёжном месте, например, в свой собственной стековой переменной. При таком подходе потоковая функция всегда будет получать правильное значение номера клиента. В качестве параметра в потоковую функцию передается идентификатор клиента, по которому сервер взаимодействует с данным клиентом. В этой функции организован разбор приходящих от клиента сообщений и команд, список которых представлен в таблице ниже. Если сообщение от клиента начинается не с команды, то оно трактуется как простой обмен сообщений между пользователями и передается игрокам в окне чата. Часто серверное программное обеспечение при работе с клиентами оперирует статическими буферами для сохранения полученной от пользователя команды. Недостатком такого подхода является ошибочная обработка данных в случае поступления более длинных команд, что часто используется злоумышленниками для нарушения работы сервера. В связи с этим, в реализованном сервере активно используется динамическая память и контроль выхода за границы буферов. Максимальный размер буфера составляет 1024 байта.

int main(void)
{
#ifdef WIN32
WSADATA wsadata;
    if(WSAStartup(MAKEWORD(1,1),&amp;wsadata) == SOCKET_ERROR){
        printf("Error creating socket.");
        return1;
    }
#endif
    int sd;
    struct sockaddr_inaddr;
    if ((sd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
    PANIC("Socket");
    addr.sin_family = AF_INET;
    addr.sin_port = htons(MY_PORT);
    addr.sin_addr.s_addr = INADDR_ANY;
    if (bind(sd,(structsockaddr) &amp;addr, sizeof(addr)) != 0)
        PANIC("Bind");
    if(listen(sd,20)!=0)
        20PANIC("Listen");
    while(1)
    {
        int client,addr_size=sizeof(addr);
        client=
#ifdef WIN32
        accept(sd,(structsockaddr)&amp;addr,&amp;addr_size);
#else
        accept(sd,(structsockaddr)&amp;addr,(socklen_t)&amp;addr_size);
#endif
        if(client>0)
        {
            pthread_tthread;
            pthread_attr_tattr;
            pthread_attr_init(&amp;attr);
            pthread_attr_setdetachstate(&amp;attr, PTHREAD_CREATE_DETACHED);
            pthread_mutex_lock(&amp;mutex);
            pthread_create(&amp;thread, &amp;attr, Child, &amp;client);
        }
    }
#ifdef WIN32
    WSACleanup();
#endif
    return0;
}

Потоковая функция коммуникации с клиентом

Сама реализация потоковой функции коммуникации с индивидуальным клиентом Child() выглядит следующим образом (параметром является идентификатор клиента):

void * Child (void *arg) 
{
    int client = *(int *) arg;
    qDebug("New client!");
    printf("New client!\n");
    map<int, string>::iterator it;

    char buffer[CNT]; //буфер для чтения вызовом read
    char data[NS]; //общий буфер сообщения

    char * i = data; //начало
    char * e = data + NS;  //конец буфера для сообщения

    ssize_t rcount;  //количество реально обработанных байтов
    uint32_t mescnt = 0; //размер блока сообщения

    while((rcount = recv(client, buffer, CNT, 0)) > 0) //Читаем <= CNT байт из сокета и записываем в buffer.
        //Количество реально прочитанных байт в rcount.
    {

        char * p = buffer;
        for(int q=0; q< rcount &amp;&amp; i< e; q++) //копируем вновь полученные байты во временный буфер, проверяем границы массива!
            *i++ = *p++;

        if(mescnt == 0) // размер блока равен нулю
        {
            if(rcount >= sizeof(uint32_t)) //заголовок сообщения передан
            {
                uint32_t mc = * (uint32_t *) data; //сетевой порядок
                mescnt = ntohl(mc); //преобразуем в прямой
            }
        }
        if(mescnt > 0) //принимаем и разбираем остаток сообщения
        {
            //поскольку неизвестно заранее, сколько пришло сообщений,
            //запускаем бесконечный цикл
            char *s = data;
            while(true)
            {
                char * real_data = s + sizeof(uint32_t);

                //сколько уже пришло байт?
                int already_read = i - real_data;
                if(already_read == mescnt) //пришло всё сообщение целиком
                {
                    analyze(real_data, mescnt, client);
                    //разбор серии сообщений закончен
                    mescnt = 0;
                    i = data;
                    break;
                }
                else //пришла либо часть сообщения, либо сразу несколько сообщений
                {
                    if(already_read < mescnt)  // пришло меньше, чем надо, читаем дальше
                        break;

                    //пришло больше, чем надо
                    analyze(real_data, mescnt, client);
                    s = real_data + mescnt;

                    if(i - s < sizeof(uint32_t)) //размер буфера не поместился
                    {
                        int q =0;
                        for( ; s < i; ++q)
                            data[q] = *s++;

                        i = data + q;
                        mescnt = 0;
                        break;
                    }

                    //размер заголовка поместился

                    uint32_t mc = * (uint32_t *) s; //сетевой порядок
                    mescnt = ntohl(mc); //преобразуем в прямой

                    //замыкаем цикл
                }
            }
        }

    }

    //На канале ошибка, закрываем наше клиентское соединение,
    //убираем коннект из карты клиентов. Поскольку это операция с общими
    //данными, то обрамляем эту конструкции в критическую секцию.

    close (client);

    string user = clients[client];

    pthread_mutex_lock( &amp;mutex );
    clients.erase(client);
    pthread_mutex_unlock( &amp;mutex );

    for ( it=clients.begin() ; it != clients.end(); it++ )
    {
        string st  = user + " closed the session.";
        writeToClient((*it).first, st.c_str(), st.size());

    }
    qCritical("Client died!");
    printf("Client died!\n");
}

Отправка сообщений клиенту

Как мы можем увидеть, в предыдущей функции используется функция writeToClient(), которая посылает сообщение клиенту. В параметры ей передается идентификатор клиента, непосредственно само сообщение и количество символов. Возвращает эта функция логическое значение успеха или неудачи операции. Её реализация представлена ниже:

bool writeToClient(int client, const char * mes, const int mc)
{

    int len = sizeof(uint32_t) + mc;
    uint32_t mescnt = htonl(mc);

    char * message = (char *) malloc(len);
    char *p = message;
    char *n = (char *) &amp; mescnt;

    for(int i =0; i< sizeof(uint32_t); ++i)
        *p++ = *n++;

    for(int i=0; i< mc; ++i)
        *p++ = *mes++;

    ssize_t  rcount = send(client, message, len,0);
    write(1, message, len);
    free(message);

    printf(" LEN %d\n", len);
    qDebug(" LEN %d", len);
    return rcount == len;
}

Анализ запросов

Кроме вышеописанной функции в потоковой функции Child() ещё используется функция analyze(). Она нужна нам для того, чтобы анализировать запросы от клиента и организовывать их обработку. Её параметрами являются сообщение, длина и номер клиента. Сообщение от клиента может начинаться с одного из следующих значений, которое определяется нашим протоколом:

Команда Описание
LOGIN Регистрация пользователя в системе. В качестве параметра передаётся логин пользователя (ник).
LIST Запрос на отображение списка всех зарегистрированных в системе пользователей.
SEND Команда отправки сообщения. Первым параметром задаётся имя пользователя, а затем указывается передаваемая строка текста.
WELCOME Запрос на начало игры от пользователя, передаваемого в качестве параметра.
ACCEPT Одобрение запроса на начало игры.
REJECT Отказ пользователя от предлагаемой игры.

Реализуем эту функцию следующим образом:

void analyze(char *mes, int size, int client)
{
    mes += sizeof(uint32_t);
    size -= sizeof(uint32_t);

    map<int, string>::iterator it; //для доступа к данным карты

    //первое слово команда, затем зависимые от команды данные

    char * cmd = mes;
    char * p = mes;
    char * e = mes + size;

    while(p < e &amp;&amp; *p != ' ') p++;
    if(p == e) return;
    *p='\0';

    if(!strcmp(cmd, "LOGIN"))
    {
        char *n = ++p;
        while(p < e &amp;&amp; *p != ' ') p++;
        *p ='\0';

        for ( it=clients.begin() ; it != clients.end(); it++ )
        {
            string st  = string(n) + " is OnLine!";
            writeToClient((*it).first, st.c_str(), st.size());
        }

        pthread_mutex_lock( &amp;mutex );
        clients[client] = n;
        pthread_mutex_unlock( &amp;mutex );

        //отправляем пользователю приветствие
        const char * welcome = "Welcome to the Sea Batle server!";
        writeToClient(client, welcome, strlen(welcome));

    }
    else if(!strcmp(cmd, "LIST"))
    {
    size_t mlen = 0;
    string users = "LIST\n";
    for ( it=clients.begin() ; it != clients.end(); it++ )
        if((*it).first != client)
            users += (*it).second + "\n";

    writeToClient(client, users.c_str(), users.size());
    }
    else if(!strcmp(cmd, "SEND"))
    {
        char *n = ++p;
        while(p < e &amp;&amp; *p != ' ') p++;
        *p ='\0';

        bool ok = false;
        for ( it=clients.begin() ; it != clients.end(); it++ )
        {
            if((*it).second == n)
            {
                p++;
                writeToClient((*it).first, p, e-p);
                ok = true;
            }
        }
    if(!ok)
        {
            const char * out = "Пользователь не подключен к серверу.";
            writeToClient(client, out, strlen(out));
        }
    }
    else if(!strcmp(cmd, "WELCOME") || !strcmp(cmd, "ACCEPT") || !strcmp(cmd, "REJECT"))
    {
        char *n = ++p;
        while(p < e &amp;&amp; *p != ' ') p++;
        *p ='\0';

        bool ok = false;
        int ic=-1;

        for ( it=clients.begin() ; it != clients.end(); it++ )
        {
            if((*it).second == n)
            {
                p++;
        string st  = string(cmd) + " " + clients[client];
                writeToClient((*it).first, st.c_str(), st.size());
                ic = (*it).first;
                ok = true;
            }
        }
    if(!ok)
        {
            const char * out = "Пользователь не подключен к серверу.";
            writeToClient(client, out, strlen(out));
        }
        else
        {
            if(!strcmp(cmd, "ACCEPT"))
            {
                long int next = getNext();
                char s[255];
                sprintf(s,"GAMEID %ld", next);
                writeToClient(client,s, strlen(s));
                writeToClient(ic, s, strlen(s));
            }
        }
    }
}

Логирование

Также неплохим стилем будет выделить функцию для логирования. Реализовывать её можно по-разному, но нами выбрано следующее использование:

void PANIC (const char *msg)
{
    perror(msg);
//    exit(-1);
}

В конечном итоге мы получили ряд функций, которых достаточно для организации сетевого взаимодействия для нашей игры. Для удобства они сведены в таблицу:

Имя функции Возвращаемое значение Параметры Назначение
PANIC Сообщение об ошибке Выводит сообщение о критической ошибке
writeToClient Успешность операции Номер клиента, сообщение, количество символов Посылает сообщение клиенту
analyze Сообщение, длина, номер клиента Распознает сообщение от клиента согласно протоколу и отвечает
Child Номер клиента Потоковая функция, в которой идет работа с индивидуальным клиентом

Проект на OpenSource репозиториях

Полный исходный код размещён на публичном репозитории на GitHub под лицензией Apache, где он доступен для ознакомления и использования всем желающим.

Разворачивание программы

Сервер

Для компиляции сервера подходит любой компилятор языка С++ под целевую платформу. Например, g++ или msvc.

Клиент

Для компиляции клиента понадобится фреймворк Qt не ниже версии 5.3, а также скомпилированная библиотека jpeglib для работы 3D-графики.

Ссылки

  1. Репозиторий на GitHub
  2. Sockets tutorial
  3. g++