令脚本对象彼此对话!
一款非常简单的游戏可能只依赖于一个脚本,在某个地方依赖于一个宏大的“管理器”对象。但事实上,我相信即使是像《Pong》这样基础的游戏也会基于不同的对象(游戏备注:如球,球拍,带有分数的UI等)使用不同的脚本去执行。
从开发者的角度来看,将你的逻辑分解成多个脚本,每个脚本都放在场景中的一个对象上,这是非常舒服的,因为它可以帮助你更好地构思场景中每个游戏对象的角色。通过遵循“关注点分离”原则,你可以确保你的代码库在游戏玩法拓展和添加更多对象时易于理解和维护。
然而,这也意味着你的各种脚本需要相互作用才能整合到完整的游戏玩法中。让我们回到《Pong》,拥有一个四处移动但却不检查是否与球拍发生碰撞的球,或者一个不更新的UI计分板都是毫无用处的。
这就是为什么在Unity中创建游戏时,即使是最基本的游戏,你也需要找到一种方法让你的脚本相互交流。
今天,我们来看看3种方法!
方法1:使用GetComponent
好吧-首先,让我们看看最简单的技术:使用Unity的内置 GetComponent() 函数,并直接访问其他脚本。
基本上,通过在有它的对象上调用 GetComponent<MyScript>() ,您获得对c#实例的引用,该实例允许您调用其所有公共方法。
例如,假设我在场景中有一个“Manager”对象,在它上面我有这个 UIManager ,它处理我的Pong游戏的计分板。这个c#脚本包含一个 UpdateScores() 函数,用于更改界面中的当前分数标签。具体的实现细节并不重要——我们只假设它正确地获取了两个玩家的当前分数,并相应地修改了UI标签:
using UnityEngine;public class UIManager : MonoBehaviour
{
public void UpdateScores()
{
_UpdatePlayer1Score();
_UpdatePlayer2Score();
}
}
因为这个函数是公共的,我可以很容易地调用它,只要我有一个引用到我的实例 UIManager 在场景中-我可以得到这个引用 GetComponent() ,像这样:
GameObject managerObject = GameObject.Find("Manager");
UIManager uiManager = managerObject.GetComponent<UIManager>();
uiManager.UpdateScores();
这段代码可以放在项目中的任何脚本中:无论如何它都可以工作,因为它从当前上下文中检索所需的一切,以引用适当的资产并触发正确的逻辑。
所以,例如,我可以把它放在 BallManager 脚本中,这样每当球退出板时,分数就会更新:
using UnityEngine;
public class BallManager : MonoBehaviour
{
public void ExitsBoard()
{
GameObject managerObject = GameObject.Find("Manager");
UIManager uiManager = managerObject.GetComponent<UIManager>();
uiManager.UpdateScores();
}
}
当然,如果您的函数需要一些输入参数,它也可以工作。我们完全可以通过选手的分数来帮助我们 UIManager 打印出来:
using UnityEngine;
public class UIManager : MonoBehaviour
{
public void UpdateScores(int player1Score, int player2Score)
{
_UpdatePlayer1Score(player1Score);
_UpdatePlayer2Score(player2Score);
}
}
using UnityEngine;
public class BallManager : MonoBehaviour
{
private int _player1Score;
private int _player2Score;
public void ExitsBoard()
{
GameObject managerObject = GameObject.Find("Manager");
UIManager uiManager = managerObject.GetComponent<UIManager>();
uiManager.UpdateScores(_player1Score, _player2Score);
}
}
注意:使用 _GetComponent()_ 并不局限于脚本之间的通信!你可以使用 _GetComponent()_ 在玩家上引用“Rigidbody”组件,然后根据玩家的输入更新速度,或者使用“SpriteRenderer”让它在角色被击中时闪烁,等等。
所以 GetComponent() 是非常方便的-唯一的问题是,它不是很有效,它可能会导致一些优化问题,如果你在你的应用程序的关键路径使用它。
这就是为什么有另一种跨脚本发送消息和触发逻辑的方法:使用事件,更准确地说,使用 UnityEvent s。
方法2:使用UnityEvents
我们说过 GetComponent() 很好,但效率也很低。但这只是问题之一。第一种方法的另一个大问题是,它将脚本紧密地耦合在一起!
当然,从技术上讲,您是在单独的c#文件中编写逻辑的。但是,您必须确保与其他对象“共享”的函数是公共的,并且两端的原型是匹配的。而且,为了使用 GetComponent() ,你显然需要将它们作为场景中对象的组件。
换句话说,你的代码库的所有不同部分都依赖于彼此,甚至场景组织也很重要,这很烦人,对吧?
为了在一定程度上缓解这个问题并更好地分离逻辑,切换到事件可能会很有趣。通常的方法是:
- 在包含逻辑的类中创建一个静态事件
- 在
Awake()或Start()钩子中定义事件 - 为我们的事件添加一个监听器,将它链接到一个回调函数
- 最后从其他脚本调用它(以实际触发回调逻辑)
总而言之,我们可以像这样重新制作 UIManager 和 BallManager :
using UnityEngine;
using UnityEngine.Events;
public class UIManager : MonoBehaviour
{
public static UnityEvent scoresChanged;
private void Awake()
{
scoresChanged = new UnityEvent();
scoresChanged.AddListener(_OnScoresChanged);
}
private void _OnScoresChanged()
{
_UpdatePlayer1Score();
_UpdatePlayer2Score();
}
}
using UnityEngine;
public class BallManager : MonoBehaviour
{
public void ExitsBoard()
{
UIManager.scoresChanged.Invoke();
}
}
您可以看到,它使 UIManager 稍微复杂了一些,但它也使更新 BallManager 中的分数变得更加容易!我们不再需要引用场景中的特定对象,只需要调用 Invoke() 。
现在,重要的是要理解我们仍然需要在场景的某个地方实例化我们的脚本,否则事件将无法真正可用。但是它改进了我们的代码作用域,并且稍微解开了逻辑的各个部分。
同样,如果需要的话,我们可以通过声明 UnityEvent 和其他输入来传递一些额外的参数:
using UnityEngine;
using UnityEngine.Events;
public class UIManager : MonoBehaviour
{
public static UnityEvent<int, int> scoresChanged;
private void Awake()
{
scoresChanged = new UnityEvent<int, int>();
scoresChanged.AddListener(_OnScoresChanged);
}
private void _OnScoresChanged(int player1Score, int player2Score)
{
_UpdatePlayer1Score(player1Score);
_UpdatePlayer2Score(player2Score);
}
}
using UnityEngine;
public class BallManager : MonoBehaviour
{
private int _player1Score;
private int _player2Score;
public void ExitsBoard()
{
UIManager.scoresChanged.Invoke(_player1Score, _player2Score);
}
}
此外,事件还具有一些非常棒的功能:它们允许你精确地命名和定位游戏循环的特定时刻(游戏备注:例如“分数发生了变化”)。
此时,您可以看到 UnityEvent 很容易创建和设置,但是必须一个接一个地定义和准备所有这些变量可能有点麻烦。如果您只有几个重要的触发器,那么您可以定义一些 UnityEvent ;但是如果你进入更复杂的系统,更复杂的交流,你就会开始在任何地方积累越来越多的变量。
而且,更糟糕的是:因为这些变量是在项目中的特定脚本中定义的,所以你绝对需要在场景中的某个地方实例化该脚本以使事件可用。
这意味着,尽管您的系统没有 GetComponent() 那么复杂,但这些事件仍然会在脚本之间创建某种形式的依赖关系。
但是,如果您有一个更集中的工具,可以在需要时处理事件的重新调度,并抽象出发射器和接收器的依赖关系,那会怎么样呢?这要归功于一种称为“全局事件管理器”的模式。
方法:使用全局事件管理器event manager
简而言之,全局事件管理器的想法是,不是直接在脚本中设置事件,而是创建一个名为 EventManager 的c#脚本,然后在场景中实例化一次。然后,所有事件的发射、存储和检索都将通过该实例完成。
通常,你可以像下面这样创建一个全局事件管理器类:
- 首先,你需要创建一个c#字典来存储事件,例如用字符串作为事件名称的键:
using UnityEngine;
using UnityEngine.Events;
using System.Collections.Generic;
public class EventManager : MonoBehaviour
{
private Dictionary<string, UnityEvent> _events;
}
- 然后,您将创建此
EventManager的单例,以确保始终引用相同的唯一实例:
public class EventManager : MonoBehaviour
{
private Dictionary<string, UnityEvent> _events;
private static EventManager _eventManager;
public static EventManager instance
{
get {
if (!_eventManager)
{
_eventManager = FindObjectOfType(typeof(EventManager)) as EventManager;
if (!_eventManager)
Debug.LogError("There needs to be one active EventManager script on a GameObject in your scene.");
else
_eventManager.Init();
}
return _eventManager;
}
}
void Init()
{
if (_events == null)
_events = new Dictionary<string, UnityEvent>();
}
}
- 现在,您已经准备好添加
AddListener()和RemoveListener()方法,以便能够使用事件名称定义接收器。
在AddListener()函数中,您将实际创建UnityEvent对象,并在_events字典中注册它(如果它还不存在)。这样,如果没有人在监听这个事件,它实际上并没有被创建,也不会白白占用内存空间!:)
public class EventManager : MonoBehaviour
{
private Dictionary<string, UnityEvent> _events;
private static EventManager _eventManager;
public static EventManager instance { ... }
void Init() { ... }
public static void AddListener(string evtName, UnityAction listener)
{
UnityEvent evt = null;
if (instance._events.TryGetValue(evtName, out evt))
{
evt.AddListener(listener);
}
else
{
evt = new UnityEvent();
evt.AddListener(listener);
instance._events.Add(evtName, evt);
}
}
public static void RemoveListener(string evtName, UnityAction listener)
{
if (_eventManager == null) return;
UnityEvent evt = null;
if (instance._events.TryGetValue(evtName, out evt))
evt.RemoveListener(listener);
}
}
当然,当我们注册一个监听器时,我们必须传入要使用的回调函数,这是一个类型为 UnityAction 的参数(即一个简单的零参数方法)。
- 最后,您只需要添加
Trigger()函数,同样使用事件名称—显然,只有当事件在_events字典中注册时,我们才会发出该事件:
public class EventManager : MonoBehaviour
{
private Dictionary<string, UnityEvent> _events;
private static EventManager _eventManager;
public static EventManager instance { ... }
void Init() { ... }
public static void AddListener(string evtName, UnityAction listener) { ... }
public static void RemoveListener(string evtName, UnityAction listener) { ... }
public static void Trigger(string evtName)
{
UnityEvent evt = null;
if (instance._events.TryGetValue(evtName, out evt))
evt.Invoke();
}
}
您将注意到,我们的其他脚本需要的所有方法(即 Trigger() 、 AddListener() 和 RemoveListener() )都是公共的和静态的。这意味着一旦我们在场景中添加了这个脚本,那么我们就不需要做任何其他事情来访问事件系统,并在c#逻辑中发出或接收事件:
using UnityEngine;
sing UnityEngine.Events;
public class UIManager : MonoBehaviour
{
private void Awake()
{
EventManager.AddListener("scores_changed", _OnScoresChanged);
}
private void _OnScoresChanged()
{
_UpdatePlayer1Score();
_UpdatePlayer2Score();
}
}
using UnityEngine;
public class BallManager : MonoBehaviour
{
public void ExitsBoard()
{
EventManager.Trigger("scores_changed");
}
}
因此,这个全局事件管理器可以很容易地从代码库中的任何地方定义新事件,并且它提供了超弱耦合(因为唯一的要求是将我们的 EventManager 放在场景中!)。
就像以前一样,我们可以通过稍微扩展 EventManager 类来处理带有数据的事件——我们所要做的就是创建自己的 TypedEvent 类型(基于 UnityEvent<object> ,这样我们就可以传入任何我们想要的参数类型),并为承载数据的事件创建等效的方法:
[System.Serializable]
public class TypedEvent : UnityEvent<object> { }
public class EventManager : MonoBehaviour
{
private Dictionary<string, UnityEvent> _events;
private Dictionary<string, TypedEvent> _typedEvents;
private static EventManager _eventManager;
public static EventManager instance { ... }
void Init()
{
if (_events == null)
_events = new Dictionary<string, UnityEvent>();
if (_typedEvents == null)
_typedEvents = new Dictionary<string, TypedEvent>();
}
public static void AddListener(string evtName, UnityAction listener) { ... }
public static void RemoveListener(string evtName, UnityAction listener) { ... }
public static void Trigger(string evtName) { ... }
public static void AddListener(string evtName, UnityAction<object> listener)
{
TypedEvent evt = null;
if (instance._typedEvents.TryGetValue(evtName, out evt))
{
evt.AddListener(listener);
}
else
{
evt = new TypedEvent();
evt.AddListener(listener);
instance._typedEvents.Add(evtName, evt);
}
}
public static void RemoveListener(string evtName,UnityAction<object> listener)
{
if (_eventManager == null) return;
TypedEvent evt = null;
if (instance._typedEvents.TryGetValue(evtName, out evt))
evt.RemoveListener(listener);
}
public static void Trigger(string evtName, object data)
{
TypedEvent evt = null;
if (instance._typedEvents.TryGetValue(evtName, out evt))
evt.Invoke(data);
}
}
代码几乎是相同的,只是我们的 UnityAction 回调现在需要一个 object 输入参数,并且我们在 Invoke() 事件时传递这个额外的数据。
即使我们在事件数据中只有一个参数,因为它的类型是 object ,如果需要的话,我们实际上可以创建一个数组来传递多个参数:
using UnityEngine;
using UnityEngine.Events;
public class UIManager : MonoBehaviour
{
private void Awake()
{
EventManager.AddListener("scores_changed", _OnScoresChanged);
}
private void _OnScoresChanged(object data)
{
int[] scores = (int[])data;
_UpdatePlayer1Score(scores[0]);
_UpdatePlayer2Score(scores[1]);
}
}
using UnityEngine;
public class BallManager : MonoBehaviour
{
private int _player1Score;
private int _player2Score;
public void ExitsBoard()
{
EventManager.Trigger("scores_changed", new int[] { _player1Score, _player2Score });
}
}
这个工具非常灵活和强大,一旦你准备好了 EventManager c#脚本,那么剩下的代码库就可以非常轻松地使用事件了!:)
总结
解耦游戏系统非常重要,因为它可以帮助你维护游戏的健壮和模块化架构。另一方面,这也伴随着一种自然的权衡:你的各种系统需要相互讨论,这样你才能真正获得整体的游戏玩法逻辑。
要做到这一点,您有几个方法,其中有我们在这里看到的3个方法:内置的 GetComponent() 、 UnityEvent s,甚至是全局事件管理器。
那么,您认为怎么样:我是否忘记了其他用于脚本通信的好方法?请留下你自己的意见