使用SharedEvents进行角色管理


链接到项目

在本文中,我想展示如何使用SharedEvents来控制提供标准资产集的第三人称角色。 我在之前的文章( thisthis )中写了有关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组件添加一个包装或装饰器的组件。 由于我不会更改现有(第3方)组件,因此将使用装饰器与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组件



并添加新的MouseHandlerComponentAgentWrapperComponentCharacterWrapperComponent

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

现在,您需要将SharedStateComponent基类中的抽象OnUpdate方法更改为异步,以便它返回在此方法的实现内部启动的任务,并将其重命名为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 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个地方可以做到这一点。 第一个是角色的装饰工,我们问他方向。 在CharacterWrapperComponent组件中

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

第二个是代理的装饰器,我们在其中指示代理的位置。 在AgentWrapperComponent组件中

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

现在将没有错误,代码将正常工作。 您可以开始查看。

一点重构


在一切准备就绪并且一切正常之后,您可以对代码进行一些梳理,使其更加方便和简单。 这将需要一些更改。

为了不创建任务数组并将其仅手动放入其中,可以创建扩展方法。 对于所有扩展方法,您可以使用同一文件将其传输到通知以及所有类。 它将位于系统文件夹中,称为扩展



在内部,我们将创建一个简单的通用扩展方法,该方法会将所有实例包装在数组中

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

仅此而已。 仍然可以运行游戏,并确保在重构​​期间没有任何损坏,并且一切正常。

Source: https://habr.com/ru/post/zh-CN439194/


All Articles