netlib.narod.ru< Назад | Оглавление | Далее >

Работа над игровым сервером

Вы уже читали о том, насколько простым может быть сервер. Чтобы претворить теорию в практику (и помочь вам создать собственные многопользовательские игры) я создал серверное приложение, которое вы найдете на прилагаемом к книге CD-ROM (в папке \BookCode\Chap15\Server). В этом разделе вы узнаете как разработать лежащую в основе архитектуру сервера сетевой игры и создать серверное приложение.

Игровой сервер по своей природе главенствующий. Для обработки графики, работы с сетью и ввода вы можете использовать игровое ядро. Поскольку в главе 6, «Создаем ядро игры», рассказывалось как использовать игровое ядро, я пропущу формальности и перейду к сути. Чтобы использовать серверное приложение следуйте инструкции:

  1. Найдите приложение Server на прилагаемом к книге CD-ROM. Когда вы запустите приложение, появится диалоговое окно Configure Server (рис. 15.9).


    Рис. 15.9. Диалоговое окно Configure Server

    Рис. 15.9. Диалоговое окно Configure Server, появляющееся сразу после запуска приложения сервера, позволяет выбрать TCP/IP-адаптер, который будет использоваться на главном узле игры


  2. В диалоговом окне Configure Server выберите TCP/IP-адаптер, который будет использоваться на главном узле игры.

  3. Щелкните OK для запуска игры. Откроется окно Network Server Demo (рис. 15.10). В этом окне отображается IP-адрес сервера, количество игроков и список подключенных к серверу игроков (если такие есть).


    Рис. 15.10. Окно Network Server Demo

    Рис. 15.10. Окно Network Server Demo отображает IP-адрес главного узла, количество подключенных игроков и имя каждого игрока


    Теперь игроки могут подключаться к серверу и играть в игру. Одновременно к игре может быть подключено только восемь игроков, но вы можете увеличить их количество в исходном коде.

  4. Для закрытия сервера (и отключения всех игроков) нажмите Alt+F4.

Поэкспериментировав с сервером двигайтесь дальше и загрузите исходный код.

Настоящая работа происходит за кулисами, где обрабатываются сообщения, перемещаются персонажи и происходит управление всем игровым миром. Приложение использует наследуемый от cApplication класс с именем cApp; части класса серверного приложения вы увидите в этом разделе.

Пропустив стандартные функции инициализации приложения (такие, как инициализация графики и системы ввода), пройдемся шаг за шагом по функциональности сервера, начав с поддержки игроков.

Хранение информации игрока

Игрокам в игре разрешено только бродить вокруг и размахивать своим оружием (задевая других игроков). Сервер отслеживает текущее состояние каждого игрока (ходьба, остановка, взмах оружием или получение повреждений), координаты в мире, направление в котором обращен игрок и скорость его ходьбы (если он идет).

Эти данные игрока хранятся внутри структуры с именем sPlayer. Поскольку у каждого подключенного игрока есть его собственный уникальный набор данных, для хранения информации выделяется массив структур sPlayer. Количество выделяемых структур с данными игроков и максимальное количество подключенных к игровой сессии игроков хранится в макросе MAX_PLAYERS и изначально установлено равным 8.

Структура sPlayer выглядит так (с вспомогательными макросами задания состояний):

// Состояния игрока
#define STATE_IDLE  1
#define STATE_MOVE  2
#define STATE_SWING 3
#define STATE_HURT  4

typedef struct sPlayer {
    BOOL  Connected;   // TRUE если игрок подключен

    char  Name[256];   // Имя игрока
    DPNID dpnidPlayer; // DirectPlay ID игрока

    long  State;       // Последнее известное состояние (STATE_*)
    long  Time;        // Время последнего обновления состояния
    long  Latency;     // Половина общего запаздывания в мс

    float XPos, YPos, ZPos; // Координаты игрока
    float Direction;   // Угол направления взгляда
    float Speed;       // Скорость перемещения

    // Конструктор
    sPlayer() { Connected = FALSE; Latency = 0; }
} sPlayer;

Структура sPlayer не так уж велика; у вас есть флаг подключения игрока, имя игрока, идентификатор игрока из DirectPlay, текущее состояние игрока (задаваемое с помощью макроопределений состояний), время последнего изменения состояния, значение сетевого запаздывания, координаты игрока, направление и скорость перемещения.

Переменные в sPlayer самодокументируемы, за исключением Latency. Вспомните, что запаздывание — это задержка, являющаяся результатом передачи данных по сети. Сохраняя время, требуемое сообщению чтобы дойти от сервера до клиента (и наоборот), мы повышаем синхронизированность зависящих от времени вычислений между сервером и клиентом.

Раз речь зашла о зависящих от времени вычислениях, именно для этого предназначена переменная Time. Всякий раз, когда сервер обновляет данные игроков, он должен знать время, которое прошло между обновлениями. Каждый раз, когда меняется состояние игрока (по запросу от клиента), в переменной Time сохраняется текущее время (минус время запаздывания).

Время также используется для управления действиями. Если игрок взмахивает мечом, сервер прекращает принимать от клиента последующие изменения состояния, пока состояние взмаха мечом не будет очищено. Как происходит очистка состояния? После того, как пройдет заданный промежуток времени! Через одну секунду цикл обновления игрока очищает состояние игрока, переводя его в режим ожидания, что позволяет клиенту снова отправлять сообщения изменения состояния.

Что касается сути передаваемых сообщений, посмотрим как сервер работает с входящими сетевыми сообщениями.

Обработка сообщений

Вы уже видели сообщения DirectPlay в действии, а сейчас сосредоточимся на сообщениях об игровых действиях (изменении состояния). Поскольку у DirectPlay есть только три функции, которые интересны для обработки входящих сетевых сообщений (CreatePlayer, DestroyPlayer и Receive), серверу необходимо преобразовать входящие сетевые сообщения в сообщения более соответствующие игровому процессу.

Сервер получает сообщения от клиента через сетевую функцию DirectPlay Receive. Эти сообщения сохраняются в буфере pReceiveData, находящемся внутри структуры DPNMSG_RECEIVE, передаваемой функции Receive. Для буфера выполняется преобразование типа к более удобному для применения в игре виду сообщения, помещаемому в очередь сообщений игры.

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

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

Я взял на себя смелость выделить заголовок в отдельную структуру, что позволит повторно использовать заголовок в каждом игровом сообщении.

// Структура заголовка сообщения,
// используемая во всех сообщениях
typedef struct {
    long  Type;     // Тип сообщения (MSG_*)
    long  Size;     // Размер передаваемых данных
    DPNID PlayerID; // Игрок, выполняющий действие
} sMessageHeader;

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

// Структура сообщения из очереди сообщений
typedef struct {
    sMessageHeader Header;    // Заголовок сообщения
    char           Data[512]; // Данные сообщения
} sMessage;

Элементарно, правда? Структуре sMessage необходимо хранить только заголовок сообщения и массив байтов, используемых для хранения зависящих от сообщения данных. Чтобы использовать конкретное сообщение вы должны выполнить приведение типа структуры sMessage к другой структуре для доступа к данным.

Например, вот структура, представляющая сообщение об изменении состояния:

// Сообщение об изменении состояния
typedef struct {
    sMessageHeader Header;  // Заголовок сообщения
    long State;             // Состояние (STATE_*)
    float XPos, YPos, ZPos; // Координаты игрока
    float Direction;        // Направление взгляда игрока
    float Speed;            // Скорость ходьбы игрока
    long Latency;           // Значение запаздывания соединения
} sStateChangeMessage;

Для приведения типа структуры sMessage, содержащей сообщение об изменении состояния к пригодной для использования структуре sStateChangeMessage вы можете использовать следующий фрагмент кода:

sMessage Msg;       // Сообщение, содержащее данные

sStateChangeMessage *scm = (sStateChangeMessage*)Msg;

// Доступ к данным сообщения об изменении состояния
scm->State = STATE_IDLE;
scm->Direction = 1.57f;

Помимо сообщения об изменении состояния в прототипе сетевой игры используются следующие структуры сообщений:

// Сообщение о создании игрока
typedef struct {
    sMessageHeader Header;  // Заголовок сообщения
    float XPos, YPos, ZPos; // Координаты создания игрока
    float Direction;        // Направление игрока
} sCreatePlayerMessage;

// Сообщение запроса информации игрока
typedef struct {
    sMessageHeader Header; // Заголовок сообщения
    DPNID PlayerID;        // Какой игрок запрашивается
} sRequestPlayerInfoMessage;

// Сообщение об уничтожении игрока
typedef struct {
    sMessageHeader Header; // Заголовок сообщения
} sDestroyPlayerMessage;

У каждого сообщения также есть связанное макроопределение, используемое и клиентом и сервером. Эти макросы сообщений являются значениями, хранящимися в переменной sMessageHeader::Type. Макроопределения типов сообщений следующие:

// Типы сообщений
#define MSG_CREATE_PLAYER  1
#define MSG_PLAYER_INFO    2
#define MSG_DESTROY_PLAYER 3
#define MSG_STATE_CHANGE   4

Вы увидите каждое сообщение в действии в разделах «Обработка игровых сообщений» и «Работа над игровым клиентом», позднее в этой главе, но сейчас проверим как сервер управляет этими относящимися к игре сообщениями.

От сообщений DirectPlay к игровым сообщениям

Как я упоминал ранее, серверу необходимо преобразовывать сетевые сообщения DirectPlay в относящиеся к игре сообщения, о которых вы только что прочитали. Вы достигаете этого обрабатывая входящие сообщения подключения игрока, отключения и получения данных из DirectPlay и преобразуя их в игровые сообщения.

Чтобы осуществить это преобразование сообщений вы наследуете класс от cNetworkServer и переопределяете функции CreatePlayer, DestroyPlayer и Receive:

class cServer : public cNetworkServer
{
  private:
    BOOL CreatePlayer(DPNMSG_CREATE_PLAYER *Msg);
    BOOL DestroyPlayer(DPNMSG_DESTROY_PLAYER *Msg);
    BOOL Receive(DPNMSG_RECEIVE *Msg);
};

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

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


Рис. 15.11. Сетевой компонент пересылает входящие сообщения из переопределенных функций

Рис. 15.11. Сетевой компонент пересылает входящие сообщения из переопределенных функций CreatePlayer, DestroyPlayer и Receive в соответствующие открытые функции компонента приложения


Чтобы использовать эти три функции сообщений в компоненте приложения вы конструируете наследуемый от cApplication класс, содержащий следующие три открытые функции:

class cApp : public cApplication
{

    // Предыдущие данные и функции cApp

  private:
    cServer m_Server; // Включаем наследуемый класс сетевого сервера

  public:
    // Функции для перекачки сетевых сообщений в приложение
    BOOL CreatePlayer(DPNMSG_CREATE_PLAYER *Msg);
    BOOL DestroyPlayer(DPNMSG_DESTROY_PLAYER *Msg);
    BOOL Receive(DPNMSG_RECEIVE *Msg);
};

Чтобы начать посылать сообщения DirectPlay классу приложения, ваш код переопределяет функции cServer, чтобы вызывать соответствующие функции приложения. Чтобы сервер знал, какому экземпляру класса приложения отправлять сообщения, вам необходимо объявить глобальную переменную, указывающую на используемый в данный момент экземпляр класса приложения:

cApp *g_Application = NULL;

Внутри конструктора наследуемого класса приложения вы затем присваиваете глобальной переменной g_Application экземпляр класса приложения:

cApp::cApp()
{

    // Прочий код конструктора

    g_Application = this; // Установка указателя экземпляра приложения
}

Теперь вы можете закодировать компонент сетевого сервера для отправки входящих сообщений объекту приложения, определенному в глобальном указателе g_Application:

BOOL cServer::CreatePlayer(DPNMSG_CREATE_PLAYER *Msg)
{
    // Отправка сообщения экземпляру класса приложения
    // (если он есть)
    if(g_Application != NULL)
        g_Application->CreatePlayer(Msg);

    return TRUE;
}

BOOL cServer::DestroyPlayer(DPNMSG_DESTROY_PLAYER *Msg)
{
    // Отправка сообщения экземпляру класса приложения
    // (если он есть)
    if(g_Application != NULL)
        g_Application->DestroyPlayer(Msg);

    return TRUE;
}

BOOL cServer::Receive(DPNMSG_RECEIVE *Msg)
{
    // Отправка сообщения экземпляру класса приложения
    // (если он есть)
    if(g_Application != NULL)
        g_Application->Receive(Msg);

    return TRUE;
}

Компонент сервера теперь завершен и перенаправляет сетевые сообщения классу приложения. Чтобы преобразовать эти сетевые сообщения в относящиеся к игре, класс приложения должен содержать следующие открытые функции:

BOOL cApp::CreatePlayer(DPNMSG_CREATE_PLAYER *Msg)
{
    sCreatePlayerMessage cpm;

    // Инициализация данных сообщения
    cpm.Header.Type = MSG_CREATE_PLAYER;
    cpm.Header.Size = sizeof(sCreatePlayerMessage);
    cpm.Header.PlayerID = Msg->dpnidPlayer;

    QueueMessage(&cpm); // Помещаем в очередь сообщений

    return TRUE;
}

BOOL cApp::DestroyPlayer(DPNMSG_DESTROY_PLAYER *Msg)
{
    sDestroyPlayerMessage dpm;

    // Инициализация данных сообщения
    dpm.Header.Type = MSG_DESTROY_PLAYER;
    dpm.Header.Size = sizeof(sDestroyPlayerMessage);
    dpm.Header.PlayerID = Msg->dpnidPlayer;

    QueueMessage(&dpm); // Помещаем в очередь сообщений

    return TRUE;
}

BOOL cApp::Receive(DPNMSG_RECEIVE *Msg)
{
    sMessageHeader *mh = (sMessageHeader*)Msg->pReceiveData;

    // Проверяем, что у сообщения допустимый тип,
    // и помещаем его в очередь
    switch(mh->Type) {
        case MSG_PLAYER_INFO:
        case MSG_STATE_CHANGE:
            // Добавляем сообщение к очереди
            QueueMessage((void*)Msg->pReceiveData);
            break;
    }

    return TRUE;
}

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

Когда игроки отключаются от игры, конструируется и помещается в очередь сообщение отключения игрока. И, наконец, когда от клиента получены данные (отличные от системных сообщений), функция cApp::Receive проверяет указан ли допустимый тип сообщения и, если да, сообщение помещаемся в очередь.

Я продолжаю упоминать очередь сообщений, и показанные выше функции добавляют сообщения в эту очередь. Сейчас вы узнаете чем является эта очередь и как она работает.

Очередь сообщений

Сервер никогда не имеет дела непосредственно с поступившими сообщениями; вместо этого сервер извлекает сообщения из очереди. Если сообщение должно быть обработано, его необходимо вставить в очередь. Использование очереди гарантирует, что сервер никогда не увязнет, обрабатывая поступающие по сети данные.

Очередь — это просто массив структур sMessage, создаваемый при инициализации класса приложения. Для сервера я установил предел очереди равным 1024 сообщениям, но вы можете поменять этот размер просто откорректировав макроопределение NUM_MESSAGE в исходном коде.

Для отслеживания добавляемых и удаляемых сообщений в очереди используются две переменные — m_MsgHead и m_MsgTail. На рис. 15.12 показано, как очередь использует эти две переменные, чтобы отслеживать какие сообщения вставляются или извлекаются.


Рис. 15.12. Переменная m_MsgHead отмечает следующую позицию в очереди сообщений для вставки сообщения

Рис. 15.12. Переменная m_MsgHead отмечает следующую позицию в очереди сообщений для вставки сообщения. Переменная m_MsgTail — это местоположение из которого будет извлекаться сообщение


Каждый раз, когда в очередь необходимо добавить сообщение, вызывается специальная функция. Это функция cApp::QueueMessage, и она получает единственный аргумент — структуру sMessage для добавления в очередь.

Помните функции входящих сообщений из cApp (описанные в разделе «От сообщений DirectPlay к игровым сообщениям»)? Эти функции строят структуры сообщений и добавляют сообщения в очередь через QueueMessage. Посмотрите на код QueueMessage, чтобы увидеть, что при этом происходит:

BOOL cApp::QueueMessage(void *Msg)
{
    sMessageHeader *mh = (sMessageHeader*)Msg;

    // Проверка ошибок - проверяем наличие массива сообщений 
    if(m_Messages = = NULL)
        return FALSE;

    // Возврат, если в очереди не осталось места
    if(((m_MsgHead + 1) % NUM_MESSAGES) == m_MsgTail)
        return FALSE;

    // Запихиваем сообщение в очередь
    if(mh->Size <= sizeof(sMessage)) {

        // Начало критической секции
        EnterCriticalSection(&m_MessageCS);

        memcpy(&m_Messages[m_MsgHead], Msg, mh->Size);

        // Переходим к следующему пустому сообщению
        // (если мы в конце очереди, перекидываемся на начало) 
        m_MsgHead++;
        if(m_MsgHead >= NUM_MESSAGES)
            m_MsgHead = 0;

        // Покидаем критическую секцию
        LeaveCriticalSection(&m_MessageCS);
    }

    return TRUE;
}

Как видите, QueueMessage просто копирует предоставленную структуру sMessage в следующий доступный элемент массива сообщений (на который указывает m_MsgHead). Вы пока еще не встречались с двумя вещами — функциями EnterCriticalSection и LeaveCriticalSection.

Windows использует эти две функции чтобы ограничить доступ приложений к памяти (используя функцию EnterCriticalSection), позволяя выполнять модификацию памяти только одному процессу. Завершив работу с памятью вы должны сообщить об этом Windows, вызвав LeaveCriticalSection.

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

Обработка игровых сообщений

Теперь, когда игровые сообщения проделали свой путь до очереди сообщений, очередным этапом будет извлечение сообщений в каждом кадре и их обработка. Чтобы все работало быстро, за один раз могут обрабатываться только 64 сообщения (это значение задается макроопределением MESSAGE_PER_FRAME в исходном коде сервера).

Обработка сообщений происходит в функции cApp::ProcessQueuedMessages:

void cApp::ProcessQueuedMessages()
{
    sMessage *Msg;
    long Count = 0;

    // Извлекаем сообщения для обработки
    while(Count != MESSAGES_PER_FRAME && m_MsgHead != m_MsgTail) {

        // Получаем указатель на "хвостовое" сообщение
        EnterCriticalSection(&m_MessageCS);
        Msg = &m_Messages[m_MsgTail];
        LeaveCriticalSection(&m_MessageCS);

        // Обрабатываем одно сообщение в зависимости от его типа
        switch(Msg->Header.Type) {
            case MSG_PLAYER_INFO: // Запрос информации игрока
                PlayerInfo(Msg, Msg->Header.PlayerID);
                break;

            case MSG_CREATE_PLAYER: // Добавление игрока
                AddPlayer(Msg);
                break;

            case MSG_DESTROY_PLAYER: // Удаление игрока
                RemovePlayer(Msg);
                break;

            case MSG_STATE_CHANGE: // Изменение состояния игрока
                PlayerStateChange(Msg);
                break;
        }

        Count++; // Увеличиваем счетчик обработанных сообщений

        // Переходим к следующему сообщению в списке
        EnterCriticalSection(&m_MessageCS);
        m_MsgTail = (m_MsgTail + 1) % NUM_MESSAGES;
        LeaveCriticalSection(&m_MessageCS);
    }
}

Когда функция ProcessQueuedMessages проходит в цикле по очередным 64 сообщениям, она обращается к набору отдельных функций для обработки различных игровых сообщений. Эти функции обработки сообщений описываются в последующих разделах.

cApp::AddPlayer

Давайте решим — ваша игра будет крутой, и скоро появится множество игроков, присоединяющихся к ней из разных мест. Когда игрок присоединяется к игре (или, по крайней мере, пытается присоединиться), сообщение от игрока добавляется в очередь, и когда это сообщение обрабатывается, будет вызвана функция AddPlayer, чтобы найти свободное место для игрока. Если свободного места нет, игрок отключается.

BOOL cApp::AddPlayer(sMessage *Msg)
{
    long i;
    DWORD Size = 0;
    DPN_PLAYER_INFO *dpi = NULL;
    HRESULT hr;
    DPNID PlayerID;

    // Контроль ошибок
    if(m_Players == NULL)
        return FALSE;

    PlayerID = Msg->Header.PlayerID;

    // Получаем информацию игрока
    hr = m_Server.GetServerCOM()->GetClientInfo(PlayerID, dpi,
                                                &Size, 0);

    // Возвращаемся при ошибке или если пытаемся добавить сервер 
    if(FAILED(hr) && hr != DPNERR_BUFFERTOOSMALL)
        return FALSE;

    // Выделяем память для буфера данных игрока и пытаемся снова 
    if((dpi = (DPN_PLAYER_INFO*)new BYTE[Size]) == NULL)
        return FALSE;

    ZeroMemory(dpi, Size);
    dpi->dwSize = sizeof(DPN_PLAYER_INFO);
    if(FAILED(m_Server.GetServerCOM()->GetClientInfo(
                                       PlayerID, dpi, &Size, 0))) {
        delete [] dpi;
        return FALSE;
    }

К этому моменту сервер запросил у DirectPlay информацию о клиенте (как она была установлена клиентом), которая включает имя клиента. Теперь сервер сканирует массив структур sPlayer, ища такую, где флаг Connected равен FALSE (и также проверяя, что игрок уже не подключен) и, значит, данный слот свободен для присоединения игрока.

    // Проверяем, что игрока нет в списке
    for(i = 0; i < MAX_PLAYERS; i++) {
        if(m_Players[i].dpnidPlayer == PlayerID &&
           m_Players[i].Connected   == TRUE) {
            delete [] dpi;
            m_Server.DisconnectPlayer(PlayerID);
            return FALSE;
        }
    }

    // Ищем свободный слот для размещения игрока
    for(i  =0; i < MAX_PLAYERS; i++) {
        if(m_Players[i].Connected = = FALSE) {
            m_Players[i].Connected = TRUE; // Флаг подключения

            // Сохраняем DPNID # DirectPlay и имя игрока
            m_Players[i].dpnidPlayer = PlayerID;
            wcstombs(m_Players[i].Name, dpi->pwszName, 256);

            // Устанавливаем данные игрока
            m_Players[i].XPos = 0.0f;
            m_Players[i].YPos = 0.0f;
            m_Players[i].ZPos = 0.0f;
            m_Players[i].Direction = 0.0f;
            m_Players[i].Speed = 512.0f;
            m_Players[i].State = STATE_IDLE;
            m_Players[i].Latency = 0;

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

            // Рассылаем информацию о добавленном игроке
            // всем остальным игрокам
            sCreatePlayerMessage cpm;

            cpm.Header.Type = MSG_CREATE_PLAYER;
            cpm.Header.Size = sizeof(sCreatePlayerMessage);
            cpm.Header.PlayerID = PlayerID;
            cpm.XPos = m_Players[i].XPos;
            cpm.YPos = m_Players[i].YPos;
            cpm.ZPos = m_Players[i].ZPos;
            cpm.Direction = m_Players[i].Direction;

            SendNetworkMessage(&cpm, DPNSEND_NOLOOPBACK, -1);

            // Увеличиваем количество игроков
            m_NumPlayers++;

            ListPlayers(); // Список всех игроков

            delete [] dpi; // Освобождаем данные игрока

            return TRUE; // Возвращаем флаг успеха
        }
    }

    delete[] dpi; // Освобождаем данные игрока

    // Отключаем игрока - соединение запрещено
    m_Server.DisconnectPlayer(PlayerID);

    return FALSE; // Возвращаем флаг неудачи
}
cApp::RemovePlayer

Реализовав присоединение игроков к игре, дайте им возможность и выйти из нее, и это цель функции RemovePlayer. В функции RemovePlayer сервер сканирует список подключенных игроков, ища совпадение идентификационного номера DirectPlay (с отключаемым игроком) и удаляет этого игрока из списка. После завершения сканирования и удаления соответствующего игрока из списка все клиенты уведомляются об отключении игрока и сервер заново строит список существующих игроков.

BOOL cApp::RemovePlayer(sMessage *Msg)
{
    long i;

    // Контроль ошибок
    if(m_Players == NULL)
        return FALSE;

    // Поиск игрока в списке
    for(i = 0; i < MAX_PLAYERS; i++) {
        if(m_Players[i].dpnidPlayer == Msg->Header.PlayerID &&
           m_Players[i].Connected   == TRUE) {

            // Отключение игрока
            m_Players[i].Connected = FALSE; 

            // Отправка сообщения об удалении игрока
            // всем остальным игрокам
            sDestroyPlayerMessage dpm;

            dpm.Header.Type = MSG_DESTROY_PLAYER;
            dpm.Header.Size = sizeof(sDestroyPlayerMessage);
            dpm.Header.PlayerID = Msg->Header.PlayerID;

            SendNetworkMessage(&dpm, DPNSEND_NOLOOPBACK, -1);

            // Уменьшение количества игроков
            m_NumPlayers--;

            // Список всех игроков
            ListPlayers();

            return TRUE;
        }
    }

    return FALSE; // Возврат ошибки
}
cApp::PlayerInfo

К сожалению, в сетевых играх игровые сообщения иногда теряются в пути. Что если одно из этих потерянных сообщений намеревалось сообщить клиентскому приложению о присоединении к игре нового игрока? Более того, что случится, если клиент начнет получать сообщения, относящиеся к игроку, о существовании которого он не знает (из-за потерянного сообщения)?

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

BOOL cApp::PlayerInfo(sMessage *Msg, DPNID To)
{
    sRequestPlayerInfoMessage *rpim;
    sCreatePlayerMessage      cpm;
    long i;

    // Проверка ошибок
    if(m_Players == NULL)
        return FALSE;

    // Получаем указатель на запрашиваемую информацию
    rpim = (sRequestPlayerInfoMessage*)Msg;

    for(i = 0; i < MAX_PLAYERS; i++) {

        // Отправляем только если нашли в списке
        if(m_Players[i].dpnidPlayer == rpim->PlayerID &&
           m_Players[i].Connected   == TRUE) {

            // Отправляем информацию игрока тому, кто ее запросил
            cpm.Header.Type = MSG_PLAYER_INFO;
            cpm.Header.Size = sizeof(sCreatePlayerMessage);
            cpm.Header.PlayerID = rpim->PlayerID;
            cpm.XPos = m_Players[i].XPos;
            cpm.YPos = m_Players[i].YPos;
            cpm.ZPos = m_Players[i].ZPos;
            cpm.Direction = m_Players[i].Direction;

            SendNetworkMessage(&cpm, DPNSEND_NOLOOPBACK, To);

            break;
        }
    }

    return TRUE;
}
cApp::PlayerStateChange

Главной функцией обработки сообщений на стороне сервера является PlayerStateChange, получающая входящие действия от клиентов и обновляющая внутренние данные игроков.

BOOL cApp::PlayerStateChange(sMessage *Msg)
{
    sStateChangeMessage *scm, uscm;
    long i, PlayerNum;
    BOOL AllowChange;
    float XDiff, ZDiff, Dist, Angle;

    // Проверка ошибок
    if(m_Players == NULL)
        return FALSE;

    // Получаем указатель на сообщение об изменении состояния
    scm = (sStateChangeMessage*)Msg;

    // Получаем номер игрока в списке
    PlayerNum = -1;

    for(i = 0; i < MAX_PLAYERS; i++) {
        if(m_Players[i].dpnidPlayer == Msg->Header.PlayerID &&
           m_Players[i].Connected   == TRUE) {
            PlayerNum = i;
            break;
        }
    }

    if(PlayerNum == -1)
        return FALSE;

К этому моменту сервер выполнил поиск игрока, приславшего сообщение об изменении состояния. Если сообщение пришло от игрока, который не подключен, оно игнорируется. Далее вступает в действие игровая логика.

Игрокам разрешено ходить, стоять и размахивать своим оружием. Те игроки, кто уже начал размахивать оружием или подвергся нападению не могут изменять свое состояние (пока их текущее состояние не будет очищено).

    AllowChange = TRUE; // Флаг разрешения изменения состояния

    // Отказываемся обновлять игрока, размахивающего мечом
    if(m_Players[PlayerNum].State == STATE_SWING)
        AllowChange = FALSE;

    // Отказываемся обновлять атакованного игрока
    if(m_Players[PlayerNum].State == STATE_HURT)
        AllowChange = FALSE;

    // Только если разрешено обновление состояния
    if(AllowChange == TRUE) {
        // Обновляем выбранного игрока
        m_Players[PlayerNum].Time = timeGetTime();
        m_Players[PlayerNum].State = scm->State;
        m_Players[PlayerNum].Direction = scm->Direction;

        // Подстраиваем время действия, согласно запаздыванию
        m_Players[PlayerNum].Time -= m_Players[PlayerNum].Latency;

        // Отправляем данные игрока всем клиентам
        uscm.Header.Type = MSG_STATE_CHANGE;
        uscm.Header.Size = sizeof(sStateChangeMessage);
        uscm.Header.PlayerID = scm->Header.PlayerID;
        uscm.State = m_Players[PlayerNum].State;
        uscm.XPos = m_Players[PlayerNum].XPos;
        uscm.YPos = m_Players[PlayerNum].YPos;
        uscm.ZPos = m_Players[PlayerNum].ZPos;
        uscm.Direction = m_Players[PlayerNum].Direction;
        uscm.Speed = m_Players[PlayerNum].Speed;

        SendNetworkMessage(&uscm, DPNSEND_NOLOOPBACK);

Теперь состояние игрока обновлено (если это разрешено) и отправлено всем остальным подключенным игрокам. Затем, если игрок взмахивает своим оружием, сканируются все игроки, чтобы увидеть, не задел ли атакующий кого-нибудь. Если да, состояние жертвы меняется на HURT.

Также заметьте, что я смещаю значение переменной времени состояния (sPlayer::Time) на значение запаздывания игрока (sPlayer::Latency). Эта подстройка для задержек передачи по сети улучшает синхронизацию. Если вы удалите смещение на величину запаздывания, то увидите эффект скачков, когда игроки будут перемещаться по уровню.

        // Если игрок взмахнул мечом, определяем жертву
        if(scm->State == STATE_SWING) {

            // Проверяем всех игроков
            for(i = 0; i < MAX_PLAYERS; i++) {

                // Проверяем только других подключенных игроков
                if(i != PlayerNum && m_Players[i].Connected == TRUE) {
                    // Получаем расстояние до игрока
                    XDiff = (float)fabs(m_Players[PlayerNum].XPos -
                                                     m_Players[i].XPos);
                    ZDiff = (float)fabs(m_Players[PlayerNum].ZPos -
                                                     m_Players[i].ZPos);
                    Dist = XDiff * XDiff + ZDiff * ZDiff;

                    // Продолжаем, если расстояние приемлемо
                    if(Dist < 10000.0f) {
                        // Получаем угол между игроками
                        Angle = -(float)atan2((m_Players[i].ZPos -
                                             m_Players[PlayerNum].ZPos),
                                             (m_Players[i].XPos -
                                             m_Players[PlayerNum].XPos)) +
                                             1.570796f;

                        // Подстраиваем направление атакующего
                        Angle -= m_Players[PlayerNum].Direction;
                        Angle += 0.785f;     // Подстройка для FOV

                        // Ограничиваем значения углов
                        if(Angle < 0.0f)
                            Angle += 6.28f;
                        if(Angle >= 6.28f)
                            Angle -= 6.28f;

                        // Игрок поражен, если он перед атакующим (90 FOV)
                        if(Angle >= 0.0f && Angle <= 1.57f) {

Заметьте, что размахивающий мечом игрок может поразить только игрока, находящегося перед ним. Чтобы проверить, был ли другой игрок поражен во время атаки, вы сперва вычисляете расстояние, и, если персонажи находятся достаточно близко друг к другу, проверяете угол между игроками. Если атакуемый игрок находится в пределах 90-градусного поля зрения перед атакующим (как показано на рис. 15.13), его считают пораженным, и с этого момента состояние жертвы меняется на HURT.


Рис. 15.13. Чтобы игрок мог поразить другого игрока атакующий должен находиться достаточно близко и быть обращен лицом к предполагаемой жертве

Рис. 15.13. Чтобы игрок мог поразить другого игрока атакующий должен находиться достаточно близко и быть обращен лицом к предполагаемой жертве. Сервер проверяет, находится ли жертва в 90-градусном поле зрения атакующего


                            // Установить жертве состояние ранения
                            // (если это еще не сделано)
                            if(m_Players[i].State != STATE_HURT) {
                                m_Players[i].State = STATE_HURT;
                                m_Players[i].Time = timeGetTime();

                                // Отправляем сетевое сообщение
                                uscm.Header.Type = MSG_STATE_CHANGE;
                                uscm.Header.Size = sizeof(sStateChangeMessage);
                                uscm.Header.PlayerID = m_Players[i].dpnidPlayer;
                                uscm.State = m_Players[i].State;
                                uscm.XPos = m_Players[i].XPos;
                                uscm.YPos = m_Players[i].YPos;
                                uscm.ZPos = m_Players[i].ZPos;
                                uscm.Direction = m_Players[i].Direction;
                                uscm.Speed = m_Players[i].Speed;

                                SendNetworkMessage(&uscm, DPNSEND_NOLOOPBACK);
                            }
                        }
                    }
                }
            }
        }
    }

    return TRUE;
}

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

Обновление игроков

Чтобы синхронизироваться с клиентами серверу необходимо внутри поддерживать запущенной упрощенную версию игры. Эта версия игры не включает в себя графику, звук и любые другие мультимедийные возможности; ей необходимо только отслеживать действия игроков.

Сервер выполняет это отслеживание действий, обновляя текущие действия игроков каждые 33 мс (точно так же, как это делает клиентское приложение). Эти действия включают ходьбу и ожидание очистки других специальных действий (таких, как размахивание мечом и ранение).

За обновление всех игроков отвечает функция cApp::UpdatePlayers:

void cApp::UpdatePlayers()
{
    long i;
    float XMove, ZMove, Speed;
    sStateChangeMessage scm;
    long Elapsed;

    // Цикл перебора всех игроков
    for(i = 0; i < MAX_PLAYERS; i++) {
        // Обновляем только подключенных игроков
        if(m_Players[i].Connected = = TRUE) {

            // Получаем время, прошедшее от обновления состояния
            // до текущего момента
            Elapsed = timeGetTime() - m_Players[i].Time;

Сервер сканирует список игроков, определяя какие игроки подключены и вычисляя для всех подключенных игроков время, прошедшее с последнего обновления сервера. Затем, если состояние игрока установлено в STATE_MOVE, вычисленный период времени используется для перемещения игрока:

            // Обработка состояния движения игрока
            if(m_Players[i].State == STATE_MOVE) {

                // Вычисляем перемещение на основе прошедшего времени 
                Speed = (float)Elapsed / 1000.0f * m_Players[i].Speed;
                XMove = (float)sin(m_Players[i].Direction) * Speed;
                ZMove = (float)cos(m_Players[i].Direction) * Speed;

                // Проверка столкновений при движении -
                // нельзя проходить сквозь блокирующие путь препятствия.
                if(CheckIntersect(&m_LevelMesh,
                                  m_Players[i].XPos,
                                  m_Players[i].YPos + 16.0f,
                                  m_Players[i].ZPos,
                                  m_Players[i].XPos + XMove,
                                  m_Players[i].YPos + 16.0f,
                                  m_Players[i].ZPos + ZMove) == TRUE)
                    XMove = ZMove = 0.0f;

                // Обновление координат игрока
                m_Players[i].XPos += XMove;
                m_Players[i].YPos = 0.0f;          // Стоим на земле
                m_Players[i].ZPos += ZMove;
                m_Players[i].Time = timeGetTime(); // Сброс времени
            }

Затем сервер имеет дело с состояниями STATE_SWING и STATE_HURT. Эти состояния очищаются только после того как пройдет одна секунда (что определяется по прошедшему времени):

            // Очистка состояния размахивания мечом после 1 секунды
            if(m_Players[i].State == STATE_SWING) {
                if(Elapsed > 1000) {
                    m_Players[i].State = STATE_IDLE;

                    // Отправка сетевого сообщения очищаемому игроку
                    scm.Header.Type = MSG_STATE_CHANGE;
                    scm.Header.Size = sizeof(sStateChangeMessage);
                    scm.Header.PlayerID = m_Players[i].dpnidPlayer;
                    scm.XPos = m_Players[i].XPos;
                    scm.YPos = m_Players[i].YPos;
                    scm.ZPos = m_Players[i].ZPos;
                    scm.Direction = m_Players[i].Direction;
                    scm.Speed = m_Players[i].Speed;
                    scm.State = m_Players[i].State;

                    // Отправка сообщения по сети
                    SendNetworkMessage(&scm, DPNSEND_NOLOOPBACK, -1);
                }
            }

            // Очистка состояния ранения после 1 секунды
            if(m_Players[i].State == STATE_HURT) {
                if(Elapsed > 1000) {
                    m_Players[i].State = STATE_IDLE;

                    // Отправка сетевого сообщения очищаемому игроку
                    scm.Header.Type = MSG_STATE_CHANGE;
                    scm.Header.Size = sizeof(sStateChangeMessage);
                    scm.Header.PlayerID = m_Players[i].dpnidPlayer;
                    scm.XPos = m_Players[i].XPos;
                    scm.YPos = m_Players[i].YPos;
                    scm.ZPos = m_Players[i].ZPos;
                    scm.Direction = m_Players[i].Direction;
                    scm.Speed = m_Players[i].Speed;
                    scm.State = m_Players[i].State;

                    // Отправка сообщения по сети
                    SendNetworkMessage(&scm, DPNSEND_NOLOOPBACK, -1);
                }
            }
        }
    }
}

Удивительно, но это все об cApp::UpdatePlayers! Помните, что функция UpdatePlayers вызывается каждые 33 мс, так что сохраняйте ее быстрой и помните, что она является критической точкой. Как только все игроки обновлены, вам надо уведомить других игроков.

Обновление сетевых клиентов

Во всех предыдущих разделах этой главы я упоминал серверные обновления, рассылаемые клиентам для синхронизации игрового процесса. В этом и заключается назначение функции cApp::UpdateNetwork. Быстрая и прямолинейная функция UpdateNetwork рассылает каждые 100 мс текущее состояние всем подключенным клиентам.

void cApp::UpdateNetwork()
{
    long i;
    sStateChangeMessage scm;

    // Отправляем обновление всем игрокам
    for(i = 0; i < MAX_PLAYERS; i++) {

        // Рапссылаем данные только о подключенных игроках
        if(m_Players[i].Connected == TRUE) {
            scm.Header.Type = MSG_STATE_CHANGE;
            scm.Header.Size = sizeof(sStateChangeMessage);
            scm.Header.PlayerID = m_Players[i].dpnidPlayer;
            scm.XPos = m_Players[i].XPos;
            scm.YPos = m_Players[i].YPos;
            scm.ZPos = m_Players[i].ZPos;
            scm.Direction = m_Players[i].Direction;
            scm.Speed = m_Players[i].Speed;
            scm.State = m_Players[i].State;
            scm.Latency = m_Players[i].Latency;

            // Отправляем сообщение по сети
            SendNetworkMessage(&scm, DPNSEND_NOLOOPBACK);
        }
    }
}

Вычисление запаздывания

Сервер периодически вычисляет время, требуемое сообщениям, чтобы быть доставленными от клиента, и использует это запаздывание в расчетах времени при обновлении клиента, что является критически важным для поддержания синхронизации игры. Функция вычисления запаздывания называется UpdateLatency и вызывается каждые 10 секунд из главного цикла приложения(cApp::Frame).

void cApp::UpdateLatency()
{
    long i;
    DPN_CONNECTION_INFO dpci;
    HRESULT hr;

    // Перебираем всех игроков
    for(i = 0; i < MAX_PLAYERS; i++) {

        // Обрабатываем только подключенных игроков
        if(m_Players[i].Connected == TRUE) {

            // Запрашиваем параметры подключения игрока
            hr = m_Server.GetServerCOM()->GetConnectionInfo(
                               m_Players[i].dpnidPlayer, &dpci, 0);
            if(SUCCEEDED(hr)) {
                m_Players[i].Latency = dpci.dwRoundTripLatencyMS / 2;

                // Ограничиваем запаздывание 1 секундой
                if(m_Players[i].Latency > 1000)
                    m_Players[i].Latency = 1000;
            } else {
                m_Players[i].Latency = 0;
            }
        }
    }
}

Для вычисления запаздывания сервер запрашивает у DirectPlay статистику соединения через функцию IDirectPlay8Server::GetConnectInfo. Эта функция получает в аргументе структуру (DPN_CONNECTION_INFO), в которой есть переменная, представляющая время запаздывания в оба конца в миллисекундах. Сервер делит это значение запаздывания пополам и сохраняет в структуре данных каждого игрока.

Самое трудное позади!

Вы закончили разбираться во внутренностях сервера. Осталось только завернуть все это в полнофункциональное приложение. Помимо кода сервера осталось сделать совсем немного. Чтобы увидеть как настраивается приложение, посмотрите код сервера на CD-ROM. Теперь пришло время сосредоточить внимание на другой стороне сетевой игры — клиенте!


netlib.narod.ru< Назад | Оглавление | Далее >

Сайт управляется системой uCoz