В этой статье мы рассмотрим передачу данных между Сервером и Клиентом в Unreal Engine 4 реализованную на C++. В самом начале, для человека не имеющего профильного образования, это кажется чем-то непостижимо сложным. Несмотря на большое количество примеров и разборов, лично мне было очень непросто сложить целостную картину этого процесса. Но, когда критический объем полученной при прочтении информации и сделанных тестов был достигнут, пришло понимание того, как же это все работает.




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

Все что происходит на Клиенте, никому кроме Клиента не ведомо.

Пример

Запускаем игру с двумя Клиентами. На сцене кубик.Оригинал этого кубика расположен на Сервере. Он самый важный. Первая копия будет находится на первом Клиенте, а вторая — на втором Клиенте. Если мы сделаем что-то с копией объекта на любом их клиентов, то оригинал не пострадает. Изменение будет сугубо локальным, только для этого Клиента. Если же изменить оригинал, то возможны 2 основных сценария:


  1. Копии клиентов останутся без изменений.
  2. Копии клиентов будут синхронизированы с оригиналом.



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


  1. Репликация (Replication).
  2. RPC (Remote Procedure Calls).
  3. TCP.


    Репликация


Первое, что стоит безоговорочно принять:

Репликация — это дорога в одну сторону, и работает только от Сервера к Клиенту.
Второе, что необходимо знать:
Реплицировать можно только объекты или переменные класса.
И третье, важное условие:
Репликация происходит только тогда, когда произошло изменение на Сервере.

Если в Blueprint мы просто ставим галочки в нужных местах, то С++ все не намного сложнее. Главное не забыть подключить #include "UnrealNetwork.h".

Сначала рассмотрим репликацию объектов.
В конструкторе прописываем:


bReplicates = true;

Если хотим реплицировать движение:


bReplicateMovement = true;

Если нужно реплицировать подключенный компонент:


Component->SetReplicates(true);

С полным описание можно ознакомиться тут.


С репликацией переменных все несколько интереснее.
Начнем с заголовочного файла .h.
Можно просто реплицировать переменную:


UPROPERTY(Replicated)
bool bMyReplicatedVariable;

А можно запустить какую-нибудь функцию на стороне Клиента, если переменная была реплицирована. Неважно, какое именно значения приняла переменная. Важен сам факт ее изменения.


UPROPERTY(ReplicatedUsing = OnRep_MySomeFunction)
TArray<float> MyReplicatedArray;

UFUNCTION()
void OnRep_MySomeFunction();

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


Теперь перейдем к .cpp
Прописываем условия репликации:


void AMySuperActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(AMySuperActor, bMyReplicatedVariable);
    DOREPLIFETIME_CONDITION(AMySuperActor, MyReplicatedArray, COND_OwnerOnly);
}

Первая переменная bMyReplicatedVariable была реплицирована без всякого условия сразу на все Клиенты, тогда как вторая, MyReplicatedArray, была обновлена только для Клиента владельца объекта AMySuperActor, если, конечно, таковой был объявлен.


Полный список возможных условий можно найти тут.




RPC (Remote Procedure Calls)


Данный метод передачи данных, в отличие от репликации работает в обе стороны, но является более затратным. Для его использования точно так же нужно подключать #include "UnrealNetwork.h".

Важная особенность — методом RPC можно передавать переменные.
Сначала нужно сказать, что RPCs бывают с подтверждение получения посылки Reliable и без такового Unreliable. Если в первом случае Отправитель не успокоится, пока не убедится, что посылка доставлена (и будет отправлять ее снова и снова, если нет ответной весточки), то во втором, Отправителю абсолютно все равно, получил кто-то его посылку или нет. Отправил и забыл.
Там где можно — применяем Unreliable. Обычно этот метод подходит для не очень важной информации, либо для часто обновляющихся данных. Там где нельзя, в случае посылки от Сервера к Клиенту, стараемся обойтись репликацией с вызовом функции, как было показано выше.

Итак, для отправки нашей посылки от Клиента на Сервер необходимо прописать три функции:


UFUNCTION(Reliable, Server, WithValidation)
void ServerTestFunction(float MyVariable);

void ServerTestFunction_Implementation(float MyVariable);

bool ServerTestFunction_Validate(float MyVariable);

Reliable — посылка с подтверждением получения.
Server — посылка от Клиента к Серверу.
WithValidation — посылка открывается получателем только при соблюдении условий, описанных в функции bool ServerTestFunction_Validate(float MyVariable). То есть, если функция возвращает true. Параметр, обязательный только для функций, которые выполняются на Сервере.
ServerTestFunction(float MyVariable) — эту функцию вызывает клиент, если хочет, что-то отправить на сервер. В общем случае, даже не требуется описывать ее в .cpp.
ServerTestFunction_Implementation(float MyVariable) — эта функция будет вызвана непосредственно на Сервере, только если…
ServerTestFunction_Validate(float MyVariable) — данная функция выполняется на Сервере, и если возвращается true, то будет вызвана ServerTestFunction_Implementation(float MyVariable).


Для отправки посылки от Сервера на Клиент, если нас категорически не устраивает использование репликации, по сути меняется только Server на Client:


UFUNCTION(Reliable, Client, WithValidation)

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


UFUNCTION(Reliable, Client, WithValidation)
void ClientTestFunction(float MyVariable);

void ClientTestFunction_Implementation(float MyVariable);

bool ClientTestFunction_Validate(float MyVariable);

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


Существует еще один вариант отправки с Сервера, когда посылка уйдет сразу всем клиентам.


UFUNCTION(Reliable, NetMulticast, WithValidation)
void NetMulticastTestFunction();

void NetMulticastTestFunction_Implementation();

bool NetMulticastTestFunction_Validate();

Не стоит злоупотреблять этим вариантом. Подумайте, возможно вы обойдетесь репликацией.

Для Client и NetMulticast делать валидацию необязательно



Пример реализации запроса на Сервер
/* Эта функция может выполняться как на Клиенте, так и на Сервере */
void ADreampaxActor::DoSomethingWithOtherActor(ADreampaxOtherActor  *  SomeOtherActor)
{
    /* выполняем проверку, если функция запущена на Клиенте */
    if (Role < ROLE_Authority)
    {
        /* отправляем команду на Сервер, с указателем на объект,
        над которым хотим совершить действие */
        ServerDoSomethingWithOtherActor(SomeOtherActor);
        /* прерываем работу функции, 
        если не хотим выполнять ее на Клиенте */
        return;
    }
    /* попадаем сюда только если функция запущена на сервере */
    SomeOtherActor->Destroy(true);
}

/* Эта функция запускается всегда  на Сервере,
если активирована функция ServerDoSomethingWithOtherActor(SomeOtherActor)
и условие проверки пройдено */
void ADreampaxCharacter::ServerDoSomethingWithOtherActor_Implementation(ADreampaxOtherActor  *  SomeOtherActor)
{
    /* производим запуск функции, но уже гарантированно на стороне сервера */
    DoSomethingWithOtherActor(SomeOtherActor);
}

/* проверка условия на стороне Сервера, можно ли запускать
ServerDoSomethingWithOtherActor_Implementation(ADreampaxOtherActor  *  SomeOtherActor) */
bool  ADreampaxCharacter::ServerDoSomethingWithOtherActor_Validate(ADreampaxOtherActor  *  SomeOtherActor)
{
    /* в данном случае всегда возвращаем true,
    но если необходимо, то можем что-то проверить
    и вернуть fasle. Тогда функция на сервере не запустится */
    return true;
}

И в завершение ссылка на пособие, которое обязательно следует почитать.
'Unreal Engine 4' Network Compendium


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


P.S. Если заметите какие-то неточности или ошибки, пожалуйста пишите в комментариях.

Комментарии (11)


  1. n3td0g
    16.08.2018 10:36

    Несколько замечаний по статье:

    Создавать (Spawn) можно только оригинал. Т.е. создать объект на Клиенте нельзя ни при каких условиях.

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

    Реплицировать можно только объекты или переменные.

    Скорее уж объкты и их свойства, так как, например, локальную переменную объявить реплицируемой нельзя, можно только прокинуть ее через RPC.

    И пара уточнений:

    Функции ReplicatedUsing можно объявлять с параметром, тогда в нем будет предыдущее значение реплицируемой UPROPERTY.

    WithValidation — параметр, обязательный только для функций, которые выполняются на сервере. Для Client и NetMulticast делать валидацию необязательно.

    P.S. Очень полезный документ на эту тему


    1. SvarogZ Автор
      16.08.2018 22:10

      Позволю себе прокомментировать Ваши уточнения. Прошу воспринимать это как разъяснение моей позиции.


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

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


      1. Если объект расположен на уровне изначально, то у него есть представление и на сервере, и каждом клиенте. Чтобы не путаться, версию сервера я называю оригиналом, а все остальные копиями. И не важно, синхронизируется они или нет. Да, если убить версию сервера (оригинал в моей интерпретации), то останутся только копии в этом случае. Это же справедливо и для уничтожения копии, т.е. на другие представления также не повлияет.
      2. Spawn допустимо делать только на сервере. Но вот реплицировать этот объект можно только на один клиент, и дальше им пользоваться по усмотрению.
        Скорее уж объекты и их свойства

        И опять вопрос терминологии. Свойства определяются переменными.

        локальную переменную объявить реплицируемой нельзя

        Я ничего не писал про локальные переменные. Все переменные в статье объявлялись в .h, и так как это единственные способ сделать их реплицируемыми, то да, локальными они быть уже никак не могут. Согласен.

        WithValidation — параметр, обязательный только для функций, которые выполняются на сервере. Для Client и NetMulticast делать валидацию необязательно.

        Ценное замечание. Спасибо. Включу в текст статьи.



        За ссылочку спасибо. Я читал более раннюю версию этого пособия. Обязательно посмотрю и включу в статью.


      1. n3td0g
        16.08.2018 23:49

        Spawn можно свободно вызывать на клиенте, создастся объект, который будет существовать только на клиенте и иметь роль ROLE_Authority на этом клиенте. Также как и все объекты, которые находятся на уровне без репликации на каждом из клиентов будут иметь роль ROLE_Authority, так что сложно называть их «копиями», они независимы друг от друга.


        1. SvarogZ Автор
          16.08.2018 23:57

          Это странно… много раз пытался делать Spawn на клиенте и каждый раз пролучал краш.
          Можно ссылочку на описание или объяснение как это сделать?
          Для меня момент очень важный.


          1. n3td0g
            17.08.2018 00:33

            В обычном наследнике от ACharacter (для примера) вызывается обычный SpawnActor на BeginPlay:

            if (!HasAuthority()) {
            	if (!WeaponClass) {
            		return;
            	}
            
            	FActorSpawnParameters Params;
            	Params.Owner = this;
            	Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
            	Weapon = GetWorld()->SpawnActor<ABaseMeleeWeapon>(WeaponClass, Params);
            	Weapon->AttachToComponent(GetMesh(), FAttachmentTransformRules::SnapToTargetNotIncludingScale, WeaponSocket);
            }
            


            В результате на клиенте создано оружие, на сервере нет.

            image


            1. SvarogZ Автор
              17.08.2018 00:45

              Спасибо за разъяснения.

              Хммм…
              Может из-за этого у меня не шло:

              Params.Owner = this;

              Имеет значение deducated server или нет?
              Проверю еще раз, и исправлю…

              В чем разница между
              if (!HasAuthority())

              и
              if (Role < ROLE_Authority)

              Я использую второй вариант.


              1. n3td0g
                17.08.2018 01:06

                Нет, простановка Owner не обязательна. Не имеет значения dedicated или listen сервер.

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

                Метод HasAuthority() проверяет внутри, что Role == ROLE_Authority. Результат будет тот же самый, что у Вас, просто есть готовый метод для этой проверки в классе AActor.

                Если будут какие-то вопросы по работе с сетью в UE4 или по C++ в рамках UE4, то можете задавать в личку, чтобы не забивать тут комментариями.


                1. SvarogZ Автор
                  17.08.2018 01:15

                  Вот чтобы ознакомиться с темой получше, я и пишу статью.
                  Очень часто вопросы игнорируются.

                  Спасибо за предложение.


                  1. n3td0g
                    17.08.2018 01:22

                    Это хорошо, если статья поднимает важную или сложную тему, которая поможет ознакомиться с чем-то лучше.
                    Но в данном случае достаточно иметь полгода-год практики и внимательно изучать документацию и статьи по теме. Тот же метод HasAuthority() задокументирован.

                    Это конечно мое мнение, но кто-то может взять статью за основу в своем обучении и, например, будет бояться создавать объекты на клиенте.
                    Внимательнее относитесь к тому, что пишете, если я или кто-то другой не сделает замечание, то новички пойдут повторять Ваши ошибки.
                    В данном случае Вы ознакамливаете с темой, а не ознакамливаетесь сами. И на Вас лежит ответственность за обучение с ошибками.


                    1. SvarogZ Автор
                      17.08.2018 02:30

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

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

                      Тем не менее, я благодарен Вам за конструктивные советы, которые позволят сделать мой проект лучше.
                      Радует так же и то, что я пока допустил не так много ляпов.


    1. SvarogZ Автор
      16.08.2018 22:54

      Небольшой пример из проекта Dreampax


      Пример

      В проекте Dreampax я хочу использовать воксельную реализацию мира.
      Сам я такой проект от начала и до конца не потяну, поэтому объединил свои усилия с другим разработчиком.


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


      Интересно же то, как реализован процесс установки блока. После spawn кубика на сервере (с нулевыми размерами, невидимого, с отключенной коллизией и т.п.), происходит его репликация только на один клиент, где уже можно менять его положение, размер, материал и т.п. Я назвал этот блок Phantom, и это реальное применение объекта для изменений только на стороне одного клиента.


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


      Как вариант, можно было в самом начале игры создать один Phantom, реплицировать его на все клиенты, и там уже пусть каждый двигает его как хочет. Мне такой вариант кажется лучше, но пока что сделано не так.