Gestión de personajes con eventos compartidos


Enlace al proyecto

En este artículo, quiero mostrar cómo puede usar SharedEvents para controlar un personaje en tercera persona que ofrece un conjunto estándar de activos. Escribí sobre SharedEvents en artículos anteriores ( esto y esto ).

¡Bienvenido a cat!

Lo primero que necesita es tomar un proyecto con SharedState / SharedEvents implementado y agregar un conjunto estándar de activos



Creé una escena pequeña y muy simple a partir de prototipos de prefabricados



Y hornee la navegación de superficie con configuraciones estándar



Después de eso, debe agregar el prefabricado ThirdPersonCharacter a esta escena



Luego puede comenzar y asegurarse de que todo funcione de manera inmediata. Luego puede proceder a configurar el uso de la infraestructura SharedState / SharedEvents creada anteriormente. Para hacer esto, elimine el componente ThirdPersonUserController del objeto de carácter.



ya que el control manual usando el teclado no es necesario. El personaje será controlado por agentes, indicando la posición donde se moverá.

Y para que esto sea posible, debe agregar y configurar el componente NavMeshAgent al objeto de personaje



Ahora necesitas crear un controlador simple que controle al personaje
con el 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); } } } 

Y agréguelo al objeto del personaje, dele enlaces a la cámara, al controlador del personaje y al agente. Todo está disponible desde el escenario.



Y eso es todo. Esto es suficiente para controlar al personaje diciéndole al agente dónde moverse, usando el mouse (clic izquierdo).

Puedes comenzar y asegurarte de que todo funcione



Integración de eventos compartidos


Ahora que la escena base está lista, puede proceder a integrar el control de personajes a través de SharedEvents . Para hacer esto, necesitará crear varios componentes. El primero de ellos es el componente que será responsable de recibir la señal del mouse y notificar a todos los componentes que rastrean la posición del clic del mouse en la escena, solo estarán interesados ​​en las coordenadas del clic.

El componente se llamará, por ejemplo, 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; } } 

Este componente necesita una clase para enviar datos en notificaciones. Para tales clases que contendrán solo datos para notificaciones, puede crear un archivo y nombrarlo DefinedEventsData



Y agregue una clase para enviar la posición de un clic con el mouse

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

Lo siguiente que debe hacer es agregar un componente que será un contenedor o decorador, como desee, para el componente NavMeshAgent . Como no cambiaré los componentes existentes (tercero), usaré decoradores para integrar con SharedState / SharedEvents .



Este componente recibirá notificaciones sobre clics del mouse en ciertos puntos de la escena y le indicará al agente dónde moverse. Y también supervise la posición de la posición del agente en cada cuadro y cree una notificación sobre su cambio.

Este componente dependerá del componente 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); } } 


Para enviar datos, este componente necesita una clase que debe agregarse al archivo DefinedEventsData.
 public class AgentMoveEventData : EventData { public Vector3 DesiredVelocity { get; set; } } 

Esto ya es suficiente para que el personaje se mueva. Pero lo hará sin animación, ya que todavía no estamos usando ThirdPersonCharater . Y para ello, al igual que para NavMeshAgent, debe crear un decorador CharacterWrapperComponent



El componente escuchará las notificaciones sobre el cambio de posición del agente y moverá al personaje en la dirección recibida de la notificación (evento).

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

Y eso es todo. Queda por agregar estos componentes al objeto del juego del personaje. Debe crear una copia a partir de la existente, eliminar el antiguo componente AgentMouseControl



Y agregue nuevos MouseHandlerComponent , AgentWrapperComponent y CharacterWrapperComponent .

En MouseHandlerComponent debe transferir la cámara desde la escena a partir de la cual se calculará la posición del clic.





Puede comenzar y asegurarse de que todo funcione.

Sucedió así con la ayuda de SharedEvents para controlar el personaje sin tener una conexión directa entre los componentes, como en el primer ejemplo. Esto permitirá una configuración más flexible de diferentes composiciones de componentes y personalizará la interacción entre ellos.

Comportamiento asincrónico para eventos compartidos


La forma en que ahora se implementa el mecanismo de notificación se basa en la transmisión síncrona de la señal y su procesamiento. Es decir, cuantos más oyentes haya, más tardará en procesarse. Para alejarse de esto, debe implementar el procesamiento de notificaciones asíncronas. Lo primero que debe hacer es agregar una versión asincrónica del método de publicación

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

Ahora debe cambiar el método abstracto OnUpdate en la clase base SharedStateComponent a asíncrono para que devuelva las tareas que se iniciaron dentro de la implementación de este método y renómbrelo a OnUpdateAsync

 protected abstract Task[] OnUpdateAsync(); 

También necesitará un mecanismo que controle la finalización de las tareas del marco anterior, antes del actual

 private Task[] _previosFrameTasks = null; //   private async Task CompletePreviousTasks() { if (_previosFrameTasks != null && _previosFrameTasks.Length > 0) await Task.WhenAll(_previosFrameTasks); } 

El método de actualización en la clase base debe marcarse como asíncrono y verificar previamente la ejecución de tareas anteriores

 async void Update() { await CompletePreviousTasks(); //     _previosFrameTasks = OnUpdateAsync(); } 

Después de estos cambios en la clase base, puede proceder a cambiar la implementación del antiguo método OnUpdate al nuevo OnUpdateAsync . El primer componente donde se hará esto es AgentWrapperComponent . Ahora este método espera el retorno del resultado. Este resultado será una variedad de tareas. Una matriz porque en el método se pueden lanzar varios en paralelo y los procesaremos en un grupo.

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

El próximo candidato para los cambios en el método OnUpdate es MouseHandlerController . Aquí el principio es el mismo.

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

En todas las demás implementaciones donde este método estaba vacío, es suficiente reemplazarlo con

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

Eso es todo. Ahora puede comenzar, y si los componentes que procesan notificaciones de forma asincrónica no acceden a los componentes que deben procesarse en el hilo principal, como Transformar, por ejemplo, todo funcionará. De lo contrario, obtendremos errores en la consola informando que estamos accediendo a estos componentes no desde el hilo principal



Para resolver este problema, debe crear un componente que procese el código en el hilo principal. Cree una carpeta separada para las secuencias de comandos y llámela Sistema, y ​​también agregue la secuencia de comandos Dispatcher .



Este componente será un singleton y tendrá un método abstracto público que ejecutará código en el hilo principal. El principio del despachador es bastante simple. Le pasaremos los delegados para que sean ejecutados en el hilo principal, él los pondrá en la cola. Y en cada cuadro, si algo está en la cola, ejecute en el hilo principal. Este componente se agregará a la escena en una sola copia, me gusta un enfoque tan simple y efectivo.

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

Lo siguiente que debe hacer es aplicar el despachador. Hay 2 lugares para hacer esto. Primero es el decorador del personaje, allí le preguntamos la dirección. En el componente CharacterWrapperComponent

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

El segundo es el decorador del agente, allí le indicamos la posición del agente. En el componente AgentWrapperComponent

 private void OnPointToGroundGot(PointOnGroundEventData eventData) { //    Dispatcher.RunOnMainThread(() => agent.SetDestination(eventData.Point)); } 

Ahora no habrá errores, el código funcionará correctamente. Puedes comenzar y ver esto.

Un poco de refactorización


Después de que todo esté listo y todo funcione, puede peinar un poco el código y hacerlo un poco más conveniente y simple. Esto requerirá algunos cambios.

Para no crear una matriz de tareas y colocar la única manualmente, puede crear un método de extensión. Para todos los métodos de extensión, puede usar el mismo archivo para la transmisión a notificaciones, así como para todas las clases. Se ubicará en la carpeta Sistema y se denominará Extensiones.



En el interior, crearemos un método de extensión genérico simple que envolverá cualquier instancia en una matriz

 public static class Extensions { //    public static T[] WrapToArray<T>(this T source) { return new T[] { source }; } } 

El siguiente cambio está ocultando el uso directo del despachador en los componentes. En su lugar, cree un método en la clase base SharedStateComponent y use el despachador desde allí.

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

Y ahora necesita aplicar estos cambios en varios lugares. Primero, cambie los métodos donde creamos manualmente matrices de tareas y agreguemos una sola instancia
En el componente 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(); } } 

Y en el componente 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; } 

Ahora nos deshacemos del uso directo del despachador en los componentes y en su lugar llamamos al método PerformInMainThread en la clase base.

Primero en AgentWrapperComponent

 private void OnPointToGroundGot(PointOnGroundEventData eventData) { //    PerformInMainThread(() => agent.SetDestination(eventData.Point)); } 

y en el componente CharacterWrapperComponent

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

Eso es todo. Queda por ejecutar el juego y asegurarse de que nada se haya roto durante la refactorización y que todo funcione correctamente.

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


All Articles