Ссылка на проект

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

Добро пожаловать под кат!

Первое что понадобится это взять проект с реализованным SharedState/SharedEvents и добавить стандартный набор ассетов



Я создал небольшую и очень простую сцену из префабов прототипирования



И запек навигацию на поверхности со стандартными настройками



После этого нужно добавить префаб ThirdPersonCharacter на эту сцену



Далее можно запустить и убедиться что все работает из коробки. После чего можно переходить к настройки использования ранее созданной инфраструктуры SharedState/SharedEvents. Для этого нужно удалить компонент ThirdPersonUserController с объекта персонажа



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

И чтобы это стало возможным нужно добавить и настроить компонент NavMeshAgent на объект персонажа



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



using UnityEngine;
using UnityEngine.AI;
using UnityStandardAssets.Characters.ThirdPerson;

public class AgentMouseController : MonoBehaviour
{
   public NavMeshAgent agent;
   public ThirdPersonCharacter character;
   public Camera cam;

   void Start()
   {
       //Вращение перса будет осуществляться через анимацию
       agent.updateRotation = false;
   }

   void Update()
   {
       //Получаем позицию клика на карте
       if (Input.GetMouseButtonDown(0))
       {
           Ray ray = cam.ScreenPointToRay(Input.mousePosition);
           RaycastHit hit;

           if (Physics.Raycast(ray, out hit))
           {
               agent.SetDestination(hit.point);
           }
       }

       //Если агент еще не добежал, то обновляем персу направление
       if(agent.remainingDistance > agent.stoppingDistance)
       {
           character.Move(agent.desiredVelocity, false, false);
       }
       else //Если добежал, то стопаем его
       {
           character.Move(Vector3.zero, false, false);
       }
   }
}

И добавить его на объект персонажа, передать ему ссылки на камеру, контроллер персонажа и агента. Это все доступно со сцены



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

Можно запустить и убедиться что все работает



Интеграция с SharedEvents


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

Компонент будет называться, например MouseHandlerComponent



using UnityEngine;

public class MouseHandlerComponent : SharedStateComponent
{

   public Camera cam;

   #region MonoBehaviour

   protected override void OnSharedStateChanged(SharedStateChangedEventData newState)
   {
      
   }

   protected override void OnStart()
   {
       if (cam == null)
           throw new MissingReferenceException("Объект камеры не установлен");
   }

   protected override void OnUpdate()
   {
       //Обрабатываем клик левой кнопки мыши
       if (Input.GetMouseButtonDown(0))
       {
           //Берем точку по которой игрок нажал и отправляем всем компонентам уведомление
           var hit = GetMouseHit();
           Events.PublishAsync("poittogound", new PointOnGroundEventData { Sender = this, Point = hit.point });
       }
   }

   #endregion

   private RaycastHit GetMouseHit()
   {
       Ray ray = cam.ScreenPointToRay(Input.mousePosition);
       RaycastHit hit;
       Physics.Raycast(ray, out hit);

       return hit;
   }
}

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



И добавить в него один класс, для отправки позиции клика мышью

using UnityEngine;

public class PointOnGroundEventData : EventData
{
   public Vector3 Point { get; set; }
}

Следующее что нужно сделать, это добавить компонент, который будет являться оберткой или декоратором, как угодно, для компонента NavMeshAgent. Так как существующие (3th party) компоненты менять я не буду, для интеграции с SharedState/SharedEvents я буду использовать именно декораторы.



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

Данный компонент будет зависеть от компонента NavMeshAgent

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class AgentWrapperComponent : SharedStateComponent
{
   private NavMeshAgent agent;

   #region Monobehaviour

   protected override void OnSharedStateChanged(SharedStateChangedEventData newState)
   {
      
   }

   protected override void OnStart()
   {
       //Получаем агента
       agent = GetComponent<NavMeshAgent>();

       //Вращение перса будет осуществляться через анимацию
       agent.updateRotation = false;

       Events.Subscribe<PointOnGroundEventData>("pointtoground", OnPointToGroundGot);
   }

   protected override void OnUpdate()
   {
       //Передача состояния по позиции агента
       if (agent.remainingDistance > agent.stoppingDistance)
       {
           Events.Publish("agentmoved",
               new AgentMoveEventData { Sender = this, DesiredVelocity = agent.desiredVelocity });
       }
       else
       {
           Events.Publish("agentmoved",
               new AgentMoveEventData { Sender = this, DesiredVelocity = Vector3.zero });
       }
   }

   #endregion

   private void OnPointToGroundGot(PointOnGroundEventData eventData)
   {
       //Назначаем агенту новую позицию
       agent.SetDestination(eventData.Point);
   }
}


Этому компоненту для отправки данных, нужен класс, который нужно добавить в файл DefinedEventsData
public class AgentMoveEventData : EventData
{
   public Vector3 DesiredVelocity { get; set; }
}

Этого уже достаточно для того чтобы персонаж двигался. Но он будет это делать без анимации, так как мы не используем ThirdPersonCharater пока еще. И для него также как и для NavMeshAgent нужно создать декоратор CharacterWrapperComponent



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

using UnityEngine;
using UnityStandardAssets.Characters.ThirdPerson;

[RequireComponent(typeof(ThirdPersonCharacter))]
public class CharacterWrapperComponent : SharedStateComponent
{
   private ThirdPersonCharacter character;

   #region Monobehaviour

   protected override void OnSharedStateChanged(SharedStateChangedEventData newState)
   {
   }

   protected override void OnStart()
   {
       character = GetComponent<ThirdPersonCharacter>();

       Events.Subscribe<AgentMoveEventData>("agentmoved", OnAgentMove);
   }

   protected override void OnUpdate()
   {
      
   }

   #endregion

   private void OnAgentMove(AgentMoveEventData eventData)
   {
       //Двигает персонажа в направлении и запускает анимации
       character.Move(eventData.DesiredVelocity, false, false);
   }
}

И это все. Осталось добавить эти компоненты на игровой объект персонажа. Нужно создать копию из существующего, удалить старый компонент AgentMouseControl



И добавить новые MouseHandlerComponent, AgentWrapperComponent и CharacterWrapperComponent.

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





Можно запустить и убедиться что все работает.

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

Асинхронное поведение для SharedEvents


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

   
//Отправка данных data подписчикам на событие eventName асинхронно
   public async Task PublishAsync<T>(string eventName, T data) where T : EventData
   {
       if (_subscribers.ContainsKey(eventName))
       {
           var listOfDelegates = _subscribers[eventName];

           var tasks = new List<Task>();
           foreach (Action<T> callback in listOfDelegates)
           {
               tasks.Add(Task.Run(() => { callback(data); }));
           }

           await Task.WhenAll(tasks);
       }
   }

Теперь нужно изменить абстрактный метод OnUpdate в базовом классе SharedStateComponent на асинхронный, чтобы он возвращал задачи, которые были инициированы внутри имплементации этого метода и переименовать его на OnUpdateAsync

protected abstract Task[] OnUpdateAsync();

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


private Task[] _previosFrameTasks = null;
//Завершает предыдущие задачи
private async Task CompletePreviousTasks()
{
   if (_previosFrameTasks != null && _previosFrameTasks.Length > 0)
       await Task.WhenAll(_previosFrameTasks);
}

Метод Update в базовом классе нужно пометить как async и предварительно проверять выполнение предыдущих задач

async void Update()
{
   await CompletePreviousTasks();

   //Для вызова в дочерних классах
   _previosFrameTasks = OnUpdateAsync();
}

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

protected override Task[] OnUpdateAsync()
{
   //Передача состояния по позиции агента
   if (agent.remainingDistance > agent.stoppingDistance)
   {
       return new Task[] { Events.PublishAsync("agentmoved",
           new AgentMoveEventData { Sender = this, DesiredVelocity = agent.desiredVelocity }) };
   }
   else
   {
       return new Task[] { Events.PublishAsync("agentmoved",
           new AgentMoveEventData { Sender = this, DesiredVelocity = Vector3.zero }) };
   }
}

Следующий кандидат на изменения метода OnUpdate это MouseHandlerController. Тут принцип тот же

   protected override Task[] OnUpdateAsync()
   {
       //Обрабатываем клик левой кнопки мыши
       if (Input.GetMouseButtonDown(0))
       {
           //Берем точку по которой игрок нажал и отправляем всем компонентам уведомление
           var hit = GetMouseHit();
           return new Task[] { Events.PublishAsync("pointtoground", new PointOnGroundEventData { Sender = this, Point = hit.point }) };
       }

       return null;
   }

Во всех остальных имплементациях, где данный метод был пустым, достаточно заменить на

protected override Task[] OnUpdateAsync()
{
   return null;
}

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



Чтобы решить эту проблему, нужно создать компонент, который будет обрабатывать код в главном потоке. Создадим отдельную папку для скриптов и назовем ее System, а также добавим в нее скрипт Dispatcher.



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

using System;
using System.Collections;
using System.Collections.Concurrent;
using UnityEngine;

public class Dispatcher : MonoBehaviour
{

   private static Dispatcher _instance;
   private volatile bool _queued = false;
   private ConcurrentQueue<Action> _queue = new ConcurrentQueue<Action>();

   private static readonly object _sync_ = new object();

   //Запускает делегат в главном потоке
   public static void RunOnMainThread(Action action)
   {
       _instance._queue.Enqueue(action);
       lock (_sync_)
       {
           _instance._queued = true;
       }
   }

   //Инициализируется единственный инстанс и помечается как неудаляемый (синглтон)
   [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
   private static void Initialize()
   {
       if (_instance == null)
       {
           _instance = new GameObject("Dispatcher").AddComponent<Dispatcher>();
           DontDestroyOnLoad(_instance.gameObject);
       }
   }
    void Update()
   {
       if (_queued) //Выполнение очереди делегатов
       {
           while (!_queue.IsEmpty)
           {
               if (_queue.TryDequeue(out Action a))
               {
                   StartCoroutine(ActionWrapper(a));
               }
           }

           lock (_sync_)
           {
               _queued = false;
           }
       }
   }

   //Оборачивает делегат в энумератор
   IEnumerator ActionWrapper(Action a)
   {
       a();
       yield return null;
   }

}

Следующее, что нужно сделать, это применить диспатчер. Есть 2 места, где это необходимо сделать. 1-е это декоратор персонажа, там мы задаем ему направление. В компоненте CharacterWrapperComponent

private void OnAgentMove(AgentMoveEventData eventData)
{
   Dispatcher.RunOnMainThread(() => character.Move(eventData.DesiredVelocity, false, false));
}

2-е это декоратор агента, там мы указываем позицию для агента. В компоненте AgentWrapperComponent

private void OnPointToGroundGot(PointOnGroundEventData eventData)
{
   //Назначаем агенту новую позицию
   Dispatcher.RunOnMainThread(() => agent.SetDestination(eventData.Point));
}

Теперь ошибок возникать не будет, код будет работать корректно. Можно запустить и убедиться в этом.

Немного рефакторинга


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

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



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

public static class Extensions
{
   //Оборачивает экзмепляр в массив
   public static T[] WrapToArray<T>(this T source)
   {
       return new T[] { source };
   }
}

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

protected void PerformInMainThread(Action action)
{
   Dispatcher.RunOnMainThread(action);
}

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

protected override Task[] OnUpdateAsync()
{
   //Передача состояния по позиции агента
   if (agent.remainingDistance > agent.stoppingDistance)
   {
       return Events.PublishAsync("agentmoved",
           new AgentMoveEventData { Sender = this, DesiredVelocity = agent.desiredVelocity })
           .WrapToArray();
   }
   else
   {
       return Events.PublishAsync("agentmoved",
           new AgentMoveEventData { Sender = this, DesiredVelocity = Vector3.zero })
           .WrapToArray();
   }
}

И в компоненте MouseHandlerComponent

protected override Task[] OnUpdateAsync()
{
   //Обрабатываем клик левой кнопки мыши
   if (Input.GetMouseButtonDown(0))
   {
       //Берем точку по которой игрок нажал и отправляем всем компонентам уведомление
       var hit = GetMouseHit();
       return Events.PublishAsync("pointtoground",
           new PointOnGroundEventData { Sender = this, Point = hit.point })
           .WrapToArray();
   }

   return null;
}

Теперь избавимся от прямого использования диспатчера в компонентах и вместо этого будем вызывать метод PerformInMainThread в базовом классе.

Сначала в компоненте AgentWrapperComponent

private void OnPointToGroundGot(PointOnGroundEventData eventData)
{
   //Назначаем агенту новую позицию
   PerformInMainThread(() => agent.SetDestination(eventData.Point));
}

и в компоненте CharacterWrapperComponent

private void OnAgentMove(AgentMoveEventData eventData)
{
   PerformInMainThread(() => character.Move(eventData.DesiredVelocity, false, false));
}

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

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


  1. Leopotam
    06.02.2019 15:47
    +3

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

    Господи, сколько же вас еще придет из кровавого enterprise-а в мой любимый, но все более и более тормозящий геймдев…


    1. deniskozlov Автор
      06.02.2019 15:48

      :D


  1. Etlay
    06.02.2019 16:53

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


    1. deniskozlov Автор
      06.02.2019 16:57

      Какие именно аллокации, вернее под какие структуры, под которые бы проводились аллокации, по вашему мнению должы убить производительность?


      1. Etlay
        06.02.2019 17:11

        аллокации в апдейт методах

        //Обрабатываем клик левой кнопки мыши
               if (Input.GetMouseButtonDown(0))
               {
                   //Берем точку по которой игрок нажал и отправляем всем компонентам уведомление
                   var hit = GetMouseHit();
                   Events.PublishAsync("poittogound", new PointOnGroundEventData { Sender = this, Point = hit.point });
               }


         protected override void OnUpdate()
           {
               //Передача состояния по позиции агента
               if (agent.remainingDistance > agent.stoppingDistance)
               {
                   Events.Publish("agentmoved",
                       new AgentMoveEventData { Sender = this, DesiredVelocity = agent.desiredVelocity });
               }
               else
               {
                   Events.Publish("agentmoved",
                       new AgentMoveEventData { Sender = this, DesiredVelocity = Vector3.zero });
               }
           }


        Массивы
        protected override Task[] OnUpdateAsync()
        {
           //Передача состояния по позиции агента
           if (agent.remainingDistance > agent.stoppingDistance)
           {
               return new Task[] { Events.PublishAsync("agentmoved",
                   new AgentMoveEventData { Sender = this, DesiredVelocity = agent.desiredVelocity }) };
           }
           else
           {
               return new Task[] { Events.PublishAsync("agentmoved",
                   new AgentMoveEventData { Sender = this, DesiredVelocity = Vector3.zero }) };
           }
        }


        Также, то что уже сказали до меня: link

        И это только для управления одним персонажем. А если в у нас будет в сцене 5-10-20 объектов построенных на этой системе?

        Возможно для десктоп платформ это еще может работать в небольших инди проектах, то для мобильных проектов это недопустимо. На проектах которых мне довелось работать, количество аллокаций в кадр было в районе 500 — 600 байт. При больших значениях у вас GC будет вызваться и отрабатывать настолько часто и долго, что добиться стабильных 30 FPS не удасться


        1. Fraske
          06.02.2019 18:04

          Хватит врать с этой системой моя игра щас в топах стимах, спонсоры меня профинансировали, член стал больше на 5 см, бицуха на 10 см, бабы теперь не могут мимо меня пройти и вообще самое главное военкомат от меня отстал!!!


          1. AgentFire
            06.02.2019 20:22

            я смотрю у вас комментарии не сильно отличаются друг от друга-то.


            1. Fraske
              06.02.2019 21:10
              -1

              А у тебя мозг отличается от нашего слишком низким IQ.


        1. deniskozlov Автор
          06.02.2019 18:11

          Вы правы для мобильных платформ возможно это будет и сложно. Но я их не не рассматриваю для себя. Что касается количества объектов. Так вот у меня был проект, один из моих первых, так сказать. На сцене работало порядка 2-х тысяч объектов, некоторые из низ с достаточно солидным набором компонентов. Все было связано, запутано. Так как почти все в методах Update обрабатывалось. И единственная сложность в этом проекте была, это его поддержка и основная задача сформировывалась это сделать архитектуру грамотнее и удобнее, чтобы упростить и ускорить процесс. Вопросов касаемо производительности вообще не возникало, так как ниже 70 кадров в секунду никогда не проседало. Я не планирую участвовать или создавать ААА проекты, я ориентируюсь на инди разработку и статья как раз для начинающих инди разработчиков. Если вдруг у меня бы встал вопрос о просадке производительности, то тогда это бы стало более приоритетным. Но до тех пор, удобство и скорость разработки, а также maintenance проекта находится в приоритете.


          1. Tutanhomon
            07.02.2019 17:14
            +1

            Если вдруг у меня бы встал вопрос о просадке производительности
            то было бы уже поздно менять архитектуру.
            Я не планирую участвовать или создавать ААА проекты
            А вот ААА проект редкие просадки FPS из-за GC не так сильно убьют впечатление от игры, как на мобилках.


        1. SmallSnowball
          06.02.2019 19:08
          +1

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