Создание многопользовательских игр с помощью Unity

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

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

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

Введение

Чтобы продемонстрировать функциональность многопользовательской игры в Unity, а не создавать целую игру с нуля, я связался с raywenderlich, чтобы узнать, могу ли я расширить их серию уроков по Tower Defense функциональностью многопользовательской игры. Это невероятно подробный и информативный учебник по игре в жанре tower defense, и я настоятельно рекомендую ознакомиться с ним, чтобы полностью понять контекст этой записи в блоге.

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

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

Мы будем использовать Unity для разработки и Ably для реализации надежной, масштабируемой связи между устройствами.

Начало работы

Прежде всего, вам потребуется установить Unity. Лучший способ сделать это — получить Unity Hub с сайта Unity, а затем установить последнюю версию Unity. Эта версия изначально была создана для 2020.3.28f1.

С Unity в руках загрузите наш базовый проект с GitHub в ветке starting-point, затем загрузите его в Unity. Прежде чем начать что-либо, вам нужно настроить размеры игрового окна, чтобы оно отображалось правильно. В главном окне просмотра перейдите на вкладку «Игра» в верхней части, а затем измените соотношение сторон на 4:3.

Создание лобби

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

В базовом шаблоне уже есть сцена, добавленная в Assets/Scenes под названием Lobby. Это простая сцена с текстовым вводом, позволяющим игроку ввести игру, к которой он хочет присоединиться (пока все игроки вводят одинаковый ID, они будут входить в одну и ту же игру), и кнопкой для отправки.

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

Сохранение состояния игры

Для обработки сохранения ID игры мы можем создать новый скрипт на C#, который назовем StateManagerBehaviour. В этом скрипте разместите следующий код:

using UnityEngine;

public class StateManagerBehaviour : MonoBehaviour
{
    public static StateManagerBehaviour Instance;
    public string GameID;

    void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else if (Instance != null)
        {
            Destroy(this.gameObject);
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

В сцене лобби создайте пустой игровой объект и назовите его StateManager. Прикрепите к нему скрипт StateManagerBehaviour. Теперь этот объект и прикрепленный к нему сценарий будут сохраняться между сценами!

Изменение сцен

Теперь, когда мы можем сохранять игровой ID, нам нужно сделать кнопку отправки функциональной. Кнопка должна:

  • Обновить значение GameID в StateManagerBehaviour, чтобы оно соответствовало введенному в поле ввода.
  • загрузить сцену игры.

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

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using TMPro;

public class JoinGame : MonoBehaviour
{
    [SerializeField]
    private Button startButton;

    [SerializeField]
    public TMP_InputField gameIDField;

    void Start()
    {
        startButton.onClick.AddListener(() => {
            if (gameIDField.text == "") return;
            StateManagerBehaviour.Instance.GameID = gameIDField.text;
            SceneManager.LoadScene("GameScene");
        });
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Выберите кнопку JoinGameButton в иерархии и перетащите InputField в поле Game ID Field скрипта JoinGame, а также саму кнопку JoinGameButton в поле Start Button.

Теперь кнопка должна быть функциональной! Теперь мы можем загружать игровую сцену из этой сцены Lobby при сборке проекта!

Однако перед тестированием нам нужно проверить, что обе сцены добавлены в настройки сборки. Перейдите в File -> Build Settings..., и добавьте обе сцены в раздел Scenes in Build.

После этого вы можете попробовать запустить сцену Лобби, и если все в порядке, вы сможете ввести значение ID игры и нажать кнопку, чтобы загрузить игру Tower Defense!

Добавление многопользовательских возможностей

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

В качестве коммуникационного слоя мы будем использовать Ably. Ably — это платформа pub/sub в реальном времени, которая является невероятно мощной парадигмой для любой формы обмена сообщениями между фанатами. Из игры каждый клиент будет отправлять сообщения на один из серверов Ably, а другие клиенты в игре, подписавшиеся на обновления, будут получать эти сообщения.

Для использования Ably вам необходимо зарегистрировать бесплатный аккаунт. Как только вы получите аккаунт, вы сможете получить API-ключ на панели инструментов вашего аккаунта, который позволит клиентам использовать Ably программно. В этом разделе мы будем использовать API-ключ в нашем коде для начала работы, но со временем вы должны заменить его на аутентификацию по токенам, которая гарантирует, что пользователи будут иметь доступ только к недолговечным токенам, которые вы полностью контролируете в плане предоставляемых разрешений.

Получение пакета Ably Unity

С созданной учетной записью и API-ключом в руках нам теперь нужно получить одну из клиентских библиотек Ably, чтобы легко взаимодействовать с системой. Ably предоставляет клиентскую библиотеку Unity как часть своей библиотеки C#. Если вы зайдете в репозиторий Ably C# GitHub, вы сможете загрузить самую последнюю версию пакета Unity из релизов.

На момент написания статьи самая свежая версия — 1.2.8, и ее можно загрузить здесь.

Имея на руках unitypackage, нам нужно импортировать его в наш проект Unity. В верхнем меню выберите Assets -> Import Package -> Custom Package... и выберите только что загруженный unitypackage. В опциях снимите флажок с пунктов JsonNet. Если вы забыли это сделать на этом шаге, просто удалите папку, которая появится в каталоге UnityPackages.

Если вы получите ошибку о том, что JsonNet не той версии, перейдите в Edit - Project Settings. Здесь перейдите на вкладку Player, а затем в разделе ‘Configuration’, ‘Assembly Version Validation’ снимите галочку.

Совместное использование обновлений турели между клиентами

Добавив библиотеку Ably, мы теперь можем использовать ее для совместного использования добавления монстров между клиентами. Поскольку мы будем использовать Ably в нескольких сценариях, давайте создадим объект, который будет содержать один экземпляр клиентской библиотеки Ably. В сцене GameScene добавьте пустой игровой объект под названием AblyManager, и создайте скрипт для присоединения к нему под названием AblyManagerBehaviour. Внутри этого скрипта добавьте следующее:

using IO.Ably;
using IO.Ably.Realtime;
using UnityEngine;

public class AblyManagerBehaviour : MonoBehaviour
{
    private AblyRealtime realtime = new AblyRealtime(
        new ClientOptions { Key = "INSERT_ABLY_API_KEY_HERE" }
    );
    public IRealtimeChannel gameChannel;

    // Start is called before the first frame update
    void Awake()
    {
        gameChannel = realtime.Channels.Get(StateManagerBehaviour.Instance.GameID);
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Здесь мы подключаемся к Ably с помощью API-ключа для аутентификации, а затем создаем канал Ably. Каналы Ably — это средства, с помощью которых клиенты могут указать клиентов, с которыми они хотят общаться через Ably. Они однозначно идентифицируются по имени, в данном случае указанному как StateManagerBehaviour.Instance.GameID.

Мы используем объект состояния, который мы указали ранее, чтобы гарантировать, что только клиенты, которые хотят участвовать в определенной игре, идентифицированной GameID, будут использовать канал Ably.

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

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

private AblyManagerBehaviour ablyManager;
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы можем инстанцировать эту ссылку на AblyManager в функции Start:

ablyManager = GameObject.Find("AblyManager").GetComponent<AblyManagerBehaviour>();
Войти в полноэкранный режим Выйти из полноэкранного режима

Имея эту ссылку на AblyManager, мы можем использовать его Ably Channel. Нам нужно отправлять сообщение каждый раз, когда игрок нажимает на кнопку добавления или улучшения монстра, чтобы клиенты могли подписаться и сами представлять эти действия.

Публикация ввода игрока

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

void OnMouseUp()
{
    if (CanPlaceMonster())
    {
        ablyManager.gameChannel.Publish("spot:" + name, “0”);
    }
    else if (CanUpgradeMonster())
    {
        MonsterData monsterData = monster.GetComponent<MonsterData>();
        Debug.Log(monsterData.levels.IndexOf(monsterData.getNextLevel()));
        ablyManager.gameChannel.Publish("spot:" + name, monsterData.levels.IndexOf(monsterData.getNextLevel()).ToString());
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь мы проверяем на стороне клиента, можем ли мы на самом деле выполнить действие, которое пытаемся выполнить, чтобы избежать отправки ненужных сообщений. Предположив, что можем, мы отправляем сообщение на канал Ably с полем name, соответствующим месту, с которым мы взаимодействуем, и полем data, содержащим уровень, на который мы хотим перевести монстра. Поле name полезно, поскольку библиотека Ably Client может фильтровать входящие сообщения по имени, что облегчит обработку сообщений, предназначенных только для каждого монстра, в сценарии каждого монстра.

Подписка на ввод игрока

Поскольку действия отправляются на канал Ably, нам также необходимо, чтобы наши клиенты подписались на получение этих сообщений. Для хранения сообщений по мере их поступления мы будем использовать очередь.

Основная причина, по которой это необходимо сделать, а не просто обрабатывать сообщения по мере их поступления, заключается в том, что функция Ably Subscribe будет использовать поток для активного прослушивания сообщений. Это означает, что когда сообщение будет получено, обработка этого сообщения будет происходить в этом новом потоке, а не в главном потоке, что означает, что мы не будем иметь доступа ни к одному из путей кода Unity или компонентов из него. Используя очередь, мы можем обрабатывать сообщения, полученные из главного потока, в более позднее время из функции Update.

Давайте добавим очередь в верхнюю часть класса:

private Queue actions = new Queue();
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь у нас есть очередь для хранения сообщений, давайте добавим функцию subscribe для прослушивания канала Ably в функции Start, сразу после выделения ablyManager:

ablyManager.gameChannel.Subscribe("spot:" + name, message =>
{
    // Need to uniquely identify actions to avoid unintentional upgrade when trying to place, etc.
    actions.Enqueue(message.Data);
});
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы подписываемся на канал Ably Channel и действуем только на сообщения с именем, соответствующим «spot:» + имя. Это означает, что мы будем добавлять сообщения в очередь, только если они предназначены для этого монстра с именем этого монстра.

Теперь, когда мы должны получать сообщения, нам нужно действовать в соответствии с ними. В функции Update мы можем начать просматривать очередь:

// Update is called once per frame
void Update()
{
    if (actions.Count == 0) return;

    PlaceOrUpgradeMonster(int.Parse((string) actions.Dequeue()));
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Для содержимого PlaceOrUpgradeMonster мы можем просто слегка подправить старое содержимое OnMouseUp, принимая во внимание предполагаемый уровень обновления и то, возможно ли оно еще или нет.

private void PlaceOrUpgradeMonster(int index)
{
    if (CanPlaceMonster() && index == 0)
    {
        monster = (GameObject)Instantiate(monsterPrefab, transform.position, Quaternion.identity);
        AudioSource audioSource = gameObject.GetComponent<AudioSource>();
        audioSource.PlayOneShot(audioSource.clip);

        gameManager.Gold -= monster.GetComponent<MonsterData>().CurrentLevel.cost;
    }
    else if (CanUpgradeMonster())
    {
        MonsterData monsterData = monster.GetComponent<MonsterData>();
        if (monsterData.getNextLevel() == monsterData.levels[index])
        {
            monster.GetComponent<MonsterData>().increaseLevel();
            AudioSource audioSource = gameObject.GetComponent<AudioSource>();
            audioSource.PlayOneShot(audioSource.clip);

            gameManager.Gold -= monster.GetComponent<MonsterData>().CurrentLevel.cost;
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Синхронизация клиентов

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

Однако остаются еще различные вопросы синхронизации, которые нам необходимо решить:

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

Синхронизация времени начала игры

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

Добавьте кнопку в пользовательский интерфейс игры и назовите ее ‘StartButton’. Отредактируйте прикрепленный к ней текстовый элемент, чтобы он имел текст ‘Start!’.

Поскольку это будет аспектный поток игры, добавьте следующее в верхнюю часть скрипта AblyManagerBehaviour:

public bool started = false;
public Button startButton;
public void StartGame()
{
    startButton.enabled = false;
    started = true;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В инспекторе созданной нами кнопки выберите AblyManager в разделе On Click инспектора. Оттуда перейдите к выпадающему списку функций AblyManagerBehaviour и выберите функцию StartGame. Теперь при нажатии кнопка должна установить paused в false.

Теперь нам нужно проверить, началась ли игра в сценарии SpawnEnemy. Во-первых, добавьте в функцию SpawnEnemy скрипта Update следующее:

if (!ablyManager.started)
{
    // This doesn't account for any time previously passed
    lastSpawnTime = Time.time;
    return;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Нам также нужно создать ablyManager внутри SpawnEnemy, поэтому в верхней части добавьте следующую строку:

private AblyManagerBehaviour ablyManager;
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем замените содержимое Start:

void Start()
{
    lastSpawnTime = Time.time;
    gameManager = GameObject.Find("GameManager").GetComponent<GameManagerBehaviour>();
    ablyManager = GameObject.Find("AblyManager").GetComponent<AblyManagerBehaviour>();
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Если вы запустите игру сейчас, локально игра должна запуститься только после того, как игрок нажмет кнопку запуска. Однако нам все равно нужно передать это действие запуска всем остальным игрокам.

Для этого создадим функцию в классе AblyManagerBehaviour, которая будет отправлять сообщение при нажатии на кнопку, и еще одну функцию, которая будет вызывать функцию StartGame, когда клиент получит сообщение:

public void SendStartGame()
{
    gameChannel.Publish("start", "");
}
Вход в полноэкранный режим Выход из полноэкранного режима

В функции Awake в AblyManagerBehaviour мы можем добавить функцию subscribe для прослушивания этого сообщения, как мы это делали в классе PlaceMonster:

gameChannel.Subscribe("start", (msg) =>
{
    StartGame();
});
Вход в полноэкранный режим Выход из полноэкранного режима

Если теперь вы измените поведение StartButton’s On Click Behaviour для использования этой новой функции SendStartGame, у вас должно быть то же поведение, что и раньше, но теперь все клиенты должны реагировать на нажатие кнопки.

Обеспечение синхронизации

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

  • Что делать, если клиент лагает/замерзает, в результате чего он отстает в игровом процессе?
  • Что, если сообщение задерживается в достижении некоторых клиентов до такой степени, что это приводит к различным результатам между клиентами?

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

Полное взаимодействие состояний

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

Если клиент отстает, он может просто проверить, какая последняя версия состояния была передана, и скорректировать локальное представление игры в соответствии с ней.

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

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

Перемотка вперед и отложенный ввод

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

Во-первых, если клиент отстает в выполнении действий из-за того, что его машина, скажем, на мгновение замерла, он может продолжать симулировать игру в обычном режиме до того момента, когда он получит сообщения, которые являются действиями других игроков. К этим действиям будет прикреплена метка времени, указывающая, когда сообщение было получено Ably. Если мы сравним временные метки этих сообщений с временной меткой, прикрепленной к начальному сообщению «start», мы сможем увидеть, сколько времени прошло от нашего дефакто источника истины, сервера Ably, который является общим для всех клиентов.

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

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

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

Позволяя клиентам догонять текущее состояние

Самый простой способ позволить клиентам, которые отстали от текущего состояния, догнать его — это использовать переменную Time.timeScale, которая является частью Unity и влияет на частоту запуска Update и FixedUpdate. Мы можем увеличить этот параметр, когда нам нужно, чтобы клиент догнал нас, чтобы быстро прогонять события, пока клиент не догонит текущее состояние, а затем вернуться к нормальной скорости.

Чтобы определить, отстает ли клиент, нам нужно начать отслеживать, сколько тиков прошло с момента локального запуска игры, а также когда, согласно Ably, началась игра. Добавьте следующее в класс AblyManagerBehaviour, чтобы начать отслеживать количество тиков с момента запуска:

public int ticksSinceStart = 0;

void FixedUpdate()
{
    if (started)
    {
        ticksSinceStart++;
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Добавив это, мы также можем начать отслеживать время запуска согласно Ably, проверяя метку времени, прикрепленную к сообщению ‘start’, которое получают все клиенты. Измените функции StartGame и Awake в AblyManagerBehaviour следующим образом:

public DateTimeOffset? startTimeAbly;

// Start is called before the first frame update
void Awake()
{
    gameChannel = realtime.Channels.Get(StateManagerBehaviour.Instance.GameID);
    gameChannel.Subscribe("start", (msg) =>
    {
        StartGame(msg.Timestamp);
    });
}

public void StartGame(DateTimeOffset? timestamp)
{
    if (!started) startTimeAbly = timestamp;
    started = true;
}
Войти в полноэкранный режим Выход из полноэкранного режима

При этом startTimeAbly должен содержать метку времени, с которой началась игра.

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

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

public class PlaceMonsterData
{
    public int monsterID;
    public DateTimeOffset? timestamp;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Давайте заменим вызов subscribe в функции Start в PlaceMonster, чтобы использовать новую структуру:

ablyManager.gameChannel.Subscribe("spot:" + name, message =>
{
    PlaceMonsterData pmd = new PlaceMonsterData();
    pmd.monsterID = int.Parse((string)message.Data);
    pmd.timestamp = message.Timestamp;
    actions.Enqueue(pmd);
});
Вход в полноэкранный режим Выйти из полноэкранного режима

Далее измените функцию Update, чтобы она использовала новую структуру данных:

void Update()
{
    if (actions.Count == 0) return;
    PlaceMonsterData pmd = (PlaceMonsterData) actions.Peek();
    DateTimeOffset? startTime = ablyManager.startTimeAbly;
    DateTimeOffset? msgTime = pmd.timestamp;
    TimeSpan? diffTime = msgTime - startTime;
    int ticksSince = ablyManager.ticksSinceStart;
    float timeFromTicks = ticksSince * (1000 * Time.fixedDeltaTime);

    if (!diffTime.HasValue)
    {
        PlaceOrUpgradeMonster(pmd.monsterID);
        return;
    }

    if (timeFromTicks < diffTime.Value.TotalMilliseconds)
    {
        Time.timeScale = 20;
        return;
    }
    else
    {
        Time.timeScale = 1;
        actions.Dequeue();
        PlaceOrUpgradeMonster(pmd.monsterID);
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Давайте разберем, что мы здесь делаем. Мы проверяем время начала и текущий тик из класса AblyManagerBehaviour, и сравниваем его с текущим следующим элементом в очереди. Мы используем Peek для проверки следующего элемента, так как мы можем не захотеть его потреблять, если мы рассинхронизированы в текущем цикле обновления.

Мы определяем количество времени, прошедшего с начала игры, сравнивая метку начала и метку действия и сохраняя ее в diffTime.

Затем мы можем определить, сколько эффективного времени прошло с начала игры, подсчитав количество тиков, которые произошли, и умножив его на время, через которое должен происходить каждый тик (по умолчанию раз в 20 мс). Это значение хранится в Time.fixedDeltaTime, поэтому мы можем просто использовать его для вычисления timeFromTicks.

Затем мы можем сравнить фактическое прошедшее время (diffTime) и локальное игровое время (timeFromTicks). Если игровое время отстает, мы можем ускорить темп игры с помощью Time.timeScale. Здесь он установлен на 20, но вы можете изменить его так, как вам удобнее.

Если мы достигли времени, за которое игра догнала истинное «состояние», мы можем, наконец, реализовать сообщение, поместив монстра, а также убедиться, что TimeScale вернулась к 1.

Теперь вы можете проверить это, запустив игру. Начните со сцены Лобби, введите код комнаты и нажмите кнопку «Пуск». Вы должны обнаружить, что поначалу враги будут двигаться в нормальном темпе, а расстановка монстров работает как обычно.

Если вы выполните действие, которое заставит игру приостановиться, например, перейдете в другое приложение, то после возвращения вы увидите, что игра идет в обычном режиме до тех пор, пока вы не попытаетесь разместить нового монстра. Затем игра должна набрать темп, чтобы догнать истинное состояние, в котором она должна находиться!

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

Задержка размещения монстров

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

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

Однако в этом демо мы собираемся сделать нечто гораздо более простое, но работающее в подавляющем большинстве сценариев: отложить размещение монстра с момента совершения действия. То есть, если мы получим сообщение о размещении монстра с меткой времени «1000», мы не будем размещать турель в игре до момента «2000» в локальной игре. Это означает, что пока все клиенты получают это сообщение в течение 1 секунды между истинным появлением сообщения и фактическим выполнением действия, все они должны выполнить действие в игре в одно и то же время.

Для этого мы можем настроить логику стрельбы так, чтобы она не стреляла до истечения секунды после метки времени, связанной с обновлением/перемещением. С точки зрения игрока это должно сделать фактическое размещение монстров плавным и мгновенным, а фактическая «задержка» в действии будет скрыта задержкой в стрельбе монстра.

Сначала нам нужно добавить метку времени в класс ShootEnemies. Добавьте следующие переменные в верхнюю часть класса:

private AblyManagerBehaviour ablyManager;
public DateTimeOffset? Timestamp;
Вход в полноэкранный режим Выйти из полноэкранного режима

Затем добавьте ссылку на AblyManagerBehaviour в функцию Start:

ablyManager = gameObject.GetComponentInChildren<AblyManagerBehaviour>();
Войти в полноэкранный режим Выход из полноэкранного режима

Наконец, добавьте учет временной метки в начало функции Update:

if (timestamp.HasValue)
{
    DateTimeOffset? startTime = ablyManager.startTimeAbly;
    DateTimeOffset? msgTime = timestamp.GetValueOrDefault();
    TimeSpan? diffTime = msgTime - startTime;
    int ticksSince = ablyManager.ticksSinceStart;
    float timeFromTicks = ticksSince * (1000 * Time.fixedDeltaTime);
    if (timeFromTicks >= diffTime.Value.TotalMilliseconds + 1000)
    {
        timestamp = null;
    }
    else
    {
        return;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавив это, нам нужен способ передать метку времени в класс ShootEnemies монстра. Обновите функцию PlaceOrUpgradeMonster в PlaceMonster следующим образом:

private void PlaceOrUpgradeMonster(int monsterLevel, DateTimeOffset? timestamp)
{
    if (CanPlaceMonster() && monsterLevel == 0)
    {
        monster = (GameObject)Instantiate(monsterPrefab, transform.position, Quaternion.identity);
        monster.GetComponent<ShootEnemies>().timestamp = timestamp;
        AudioSource audioSource = gameObject.GetComponent<AudioSource>();
        audioSource.PlayOneShot(audioSource.clip);

        gameManager.Gold -= monster.GetComponent<MonsterData>().CurrentLevel.cost;
    }
    else if (CanUpgradeMonster())
    {
        MonsterData monsterData = monster.GetComponent<MonsterData>();
        if (monsterData.getNextLevel() == monsterData.levels[monsterLevel])
        {
            monster.GetComponent<MonsterData>().increaseLevel();
            monster.GetComponent<ShootEnemies>().timestamp = timestamp;
            AudioSource audioSource = gameObject.GetComponent<AudioSource>();
            audioSource.PlayOneShot(audioSource.clip);

            gameManager.Gold -= monster.GetComponent<MonsterData>().CurrentLevel.cost;
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Этим мы позволяем передавать метку времени из функции Update, а затем используем ее для присвоения значения timestamp в классе ShootEnemies.

Наконец, измените функцию Update в классе PlaceMonster, чтобы передать метку времени в функцию PlaceOrUpgradeMonster:

…
if (!diffTime.HasValue)
{
    PlaceOrUpgradeMonster(pmd.monsterID, msgTime);
    return;
}

if (timeFromTicks < diffTime.Value.TotalMilliseconds)
{
    Time.timeScale = 20;
    return;
}
else
{
    Time.timeScale = 1;
    actions.Dequeue();
    PlaceOrUpgradeMonster(pmd.monsterID, msgTime);
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Заключение

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

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

Существует версия игры, доступная из браузера, если вы хотите попробовать.

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

Оцените статью
devanswers.ru
Добавить комментарий