📜 ⬆️ ⬇️

Controlling a character with SharedEvents


Link to the project

In this article I want to show how you can use SharedEvents to control a third-person character that offers a standard set of assets. I wrote about SharedEvents in previous articles ( this one and this one ).

Welcome under the cut!

The first thing you need is to take a project with implemented SharedState / SharedEvents and add a standard set of assets



I created a small and very simple scene from prototyping prefabs.



And baked the navigation on the surface with standard settings



After that you need to add the ThirdPersonCharacter prefab to this scene.



Then you can run and make sure that everything works out of the box. Then you can proceed to the settings of using the previously created infrastructure SharedState / SharedEvents . To do this, remove the ThirdPersonUserController component from the character object.



since manual control using the keyboard is not needed. The character will be controlled by agents, indicating the position where he will move.

And to make this possible, you need to add and configure the NavMeshAgent component on the character object.



Now you need to create a simple controller that will control the character.
with mouse 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); } } } 

And add it to the object of the character, give him links to the camera, controller of the character and agent. It's all available from the stage.



And that's all. This is enough to control the character by telling the agent where to go, using the mouse (click the left button).

You can run and make sure everything works.



Integration with SharedEvents


Now that the base scene is ready, you can proceed to the integration of character management through SharedEvents . To do this, you will need to create several components. The first of these is the component that will be responsible for receiving the signal from the mouse and notifying all the components that track the mouse click position on the scene; they will only be interested in click coordinates.

The component will be named, for example, 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; } } 

This component needs a class to send data in notifications. For such classes, which will contain only data for notifications, you can create one file and name it DefinedEventsData



And add one class to it to send the mouse click position.

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

The next thing you need to do is add a component, which will be a wrapper or a decorator, whatever you like, for the NavMeshAgent component. Since I will not change the existing (3th party) components, I will use decorators to integrate with SharedState / SharedEvents .



This component will receive notifications about mouse clicks at certain points on the scene and tell the agent where to go. And also monitor the position of the agent's position in each frame and create a notification about its change.

This component will depend on the NavMeshAgent component .

 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); } } 


This component requires data to be sent to the DefinedEventsData file .
 public class AgentMoveEventData : EventData { public Vector3 DesiredVelocity { get; set; } } 

This is enough for the character to move. But he will do it without animation, since we do not use the ThirdPersonCharater yet. And for it as well as for NavMeshAgent, you need to create a CharacterWrapperComponent decorator



The component will listen for notifications about a change in the position of the agent, and move the character in the direction received from the notification (event).

 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); } } 

And it's all. It remains to add these components to the game object of the character. You need to create a copy from the existing one, delete the old component AgentMouseControl



And add new MouseHandlerComponent , AgentWrapperComponent and CharacterWrapperComponent .

In MouseHandlerComponent, you need to transfer the camera from the scene, from which the position of the click will be calculated.





You can run and make sure everything works.

It happened with the help of SharedEvents to control the character without having a direct connection between the components, as in the first example. This will allow you to more flexibly configure different compositions of components and customize the interaction between them.

Asynchronous behavior for SharedEvents


The way the notification mechanism is now implemented is based on synchronous signal transmission and processing. That is, the more listeners there are, the longer it will be processed. In order to get away from this, you need to implement asynchronous processing of notifications. The first thing to do is add an asynchronous version of the Publish method.

 //Отправка данных 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); } } 

Now you need to change the OnUpdate abstract method in the SharedStateComponent base class to asynchronous, so that it returns the tasks that were initiated inside the implementation of this method and rename it to OnUpdateAsync

 protected abstract Task[] OnUpdateAsync(); 

You will also need a mechanism that will control the completion of tasks from the previous frame, prior to the start of the current

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

The Update method in the base class should be marked as async and pre-checked the performance of previous tasks.

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

After these changes in the base class, you can proceed to change the implementation of the old OnUpdate method to the new OnUpdateAsync . The first component where this will be done is AgentWrapperComponent . Now this method is waiting for the return of the result. This result will be an array of tasks. An array because in the method several runs in parallel and we will process them in a bundle.

 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 }) }; } } 

The next candidate for changes to the OnUpdate method is MouseHandlerController . Here the principle is the same

  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; } 

In all other implementations where this method was empty, it is enough to replace with

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

That's all. Now you can run, and if the components that process notifications asynchronously do not access those components that should be processed in the main stream, such as Transform, for example, everything will work. Otherwise, we will get errors in the console, indicating that we are accessing these components not from the main thread.



To solve this problem, you need to create a component that will process the code in the main thread. Create a separate folder for the scripts and call it System, and add the Dispatcher script to it.



This component will be a singleton and have one public abstract method that will execute code in the main thread. Dispatcher principle is quite simple. We will hand him delegates who must be executed in the main thread; it will put them in a queue. And in each frame, if something is in the queue, execute in the main thread. This component will add itself to the stage in a single copy, I like this simple and effective approach.

 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; } } 

The next thing to do is to apply the dispatcher. There are 2 places where this needs to be done. 1st is the character decorator, where we give him direction. In the CharacterWrapperComponent component

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

2nd is the agent decorator, where we specify the position for the agent. In the AgentWrapperComponent component

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

Now there will be no errors, the code will work correctly. You can run and verify this.

Little refactoring


After everything is ready and everything works, you can brush the code a bit and make it a little more convenient and simple. This will require several changes.

In order not to create an array of tasks and place the only one in it manually, you can create an extension method. For all extension methods, you can also use the same file as for all classes to pass to notifications. It will be located in the System folder and called Extensions.



Inside we will create a simple generic extension method that will wrap any instance into an array

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

The next change is to hide the direct use of the dispatcher in the components. Instead, create a method in the SharedStateComponent base class and use the dispatcher from there.

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

And now you need to apply these changes in several places. First, change the methods, where we manually create arrays of tasks and add a single instance to them.
In the AgentWrapperComponent component

 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(); } } 

And in the MouseHandlerComponent component

 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; } 

Now we’ll get rid of the direct use of the dispatcher in components and instead call the PerformInMainThread method in the base class.

First in the AgentWrapperComponent component

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

and in the CharacterWrapperComponent component

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

That's all. It remains to start the game and make sure that during the refactoring nothing is broken and everything works correctly.

Source: https://habr.com/ru/post/439194/