
在游戏代码中使用可脚本对象作为事件通道
如何让应用程序中的不同系统协同工作?一种常见的解决方案是使用事件在对象之间发送信息。继续阅读,了解如何在 Unity 项目中将可脚本对象用作事件通道。
这是六本迷你指南系列中的第五本,旨在帮助 Unity 开发人员使用电子书中的演示、 使用脚本对象在 Unity 中创建模块化游戏架构.
该演示的灵感来源于经典的球类和桨类街机游戏机制,并展示了脚本对象如何帮助您创建可测试、可扩展和便于设计的组件。
电子书、演示项目和这些迷你指南共同提供了在 Unity 项目中使用 ScriptableObject 类编程设计模式的最佳实践。这些技巧可以帮助你简化代码、减少内存使用量并提高代码的可重用性。
本系列包括以下文章:
开始前的重要提示
在深入学习 ScriptableObject 演示项目和本系列迷你指南之前,请记住,设计模式的核心只是想法。它们并不适用于所有情况。这些技巧可以帮助你学习使用 Unity 和 ScriptableObjects 的新方法。
每种模式都各有利弊。只选择对您的具体项目有意义的项目。你们的设计师是否非常依赖 Unity 编辑器?基于 ScriptableObject 的模式是帮助他们与开发人员协作的不错选择。
归根结底,适合自己项目和团队的代码架构才是最好的代码架构。
松耦合、高内聚
在构建应用程序中的不同模块或系统时,将它们视为 "代码孤岛 "通常会有所帮助。每个模块可能有多个组件或游戏对象,它们为了一个共同的目的而协同工作。
例如,玩家的桨可以由一个解释玩家输入的脚本、一个处理移动或碰撞的脚本等组成。如果这些部件之间存在相互依赖关系,则可以使用检查器来建立这些紧密联系。
不过,请记住,每次向另一个对象添加依赖关系时,都会有少量风险。在可能的情况下,应尽量减少与外部对象的依赖关系。与模块或系统之外的事物的交流则不会那么直接。
您可以让桨脚本在游戏中引用球,但这意味着它们之间有联系。一旦它们之间存在依赖关系,对其中一个进行更改就可能会影响到另一个。
理想情况下,您希望在修改部分应用程序的同时,不会破坏其他任何程序。这样做的目的是保持模块内部的内聚性和外部的解耦性。
当检查器中缺少所需的引用时,可以使用项目的NullRefChecker类发出礼貌警告。只需在每个组件设置或初始化后,在某处(如在Awake 中)调用静态Validate方法即可。
添加自定义可选属性,如果字段可以不设置,则忽略检查。
使用事件
那么,如何让应用程序中的这些不同系统协同工作呢?
一种解决方案是使用事件在对象之间发送信息。事件遵循广播者-听众模式,如上图所示。
在这里,监听对象订阅广播器上的事件,而不是直接调用方法或引用属性。
对一个组件的更改对其他组件的影响较小。修改代码时仍会出现问题,但对象之间的联系不会那么紧密。中间的事件就像它们之间的缓冲器。
我们通常将这种广播者-听众关系中的对象描述为松散耦合。
有关事件和观察者模式的更多信息,请参阅我们的技术电子书《用游戏编程模式提升代码水平》。

中央活动系统
集中活动
在上述情况下,广播公司只负责发送信号。它不关心哪些对象在监听。
不过,监听器仍然需要了解广播者的一些信息,以便使用 OnEnable 和 OnDisable 方法订阅和取消订阅委托。
您可以将事件移至静态类中,从而进一步解耦广播器和监听器。一个通用的 "游戏事件 "类可以帮助在两者之间插入一个额外的抽象层。这可以在广播者和听众互不相识的情况下将他们联系起来。
在本例中,我们将使用静态GameEvents类,以简化操作。不过,在实际生产场景中,最好按功能将其分成更小的、专门的类,如 UIEvents、GameStateEvents、HealthEvents、InventoryEvents 等。
例如,您可以为退出应用程序、显示用户界面屏幕或加载场景创建静态事件。通过使这些事件静态化,可以从应用程序的任何部分访问它们。
例如,您可以创建GameEvents,如下图所示。
静态 GameEvent 位于原始广播者和监听者之间,是一个中介。对接收方或发送方的修改都会降低影响对方的可能性。
因此,更新代码会减少意想不到的副作用。将事件定义存储在单一位置也更便于管理。
虽然静态 GameEvents 非常有效,但游戏设计师可能不太容易使用它们。由于它们是静态的,因此必须在代码中定义,并且不能在编辑器中本地序列化。
为了使系统更便于编辑,可以考虑基于可脚本对象实施事件。
using UnityEngine;
using System;
public static class GameEvents
{
public static Action ExitApplication;
public static Action HomeScreenShown;
public static Action<float> LoadProgressUpdated;
}
活动频道在广播者和听众之间转播信号。
配置事件通道
基于脚本对象的事件为静态事件提供了图形化的替代方案。虽然两者的功能相似,但可脚本对象(ScriptableObjects)更便于设计,因为它们会出现在 "检查器 "中。
由于它们将信号从广播公司转发给收听者,因此可以将它们视为 "事件频道",类似于从无线电塔发射的信号。
任何具有以下功能的可脚本对象都可以作为事件通道:
- 委托(如 UnityAction 或 System.Action):这将通知订阅者,并将数据作为参数传递。
- 活动筹款法:此公共方法调用委托。
您可以设置任意数量的事件通道来决定游戏的各个方面。
UnityAction 和 System.Action 都是委托。您可以在项目中使用一种或两种类型。
UnityAction 可为艺术家提供更友好的体验。否则,请使用 System.Action 委托。
下面是该项目的VoidEventChannelSO示例。这是一个基于 ScriptableObject 的事件,不传递任何参数。
在这里,我们使用名为OnEventRaised 的UnityAction 并公开RaiseEventmethod。
using UnityEngine;
using UnityEngine.Events;
[CreateAssetMenu(menuName = "Events/Void Event Channel",
fileName = "VoidEventChannel")]
public class VoidEventChannelSO : DescriptionSO
{
[Tooltip("The action to perform")]
public UnityAction OnEventRaised;
public void RaiseEvent()
{
if (OnEventRaised != null)
OnEventRaised.Invoke();
}
}

在项目中创建事件通道。
创建事件通道资产
在项目中创建事件通道资产,以便使用。您可以使用创建菜单或复制现有资产。
重新命名每个资产,并使用描述字段来标识每个可脚本对象资产。请记住,每个活动频道都是作为项目级资产存在的。您将在 MonoBehaviours 中引用这些资产。
虽然是可选的,但你可以用 _SO 后缀标记基于脚本对象的事件通道,以区别于其他携带数据的脚本对象(后缀为 _Data)。
文件夹和命名约定有助于项目保持有序。您需要根据自己项目的需要对其进行定制。阅读创建 C# 样式指南获取更多信息。

在检查器中指定事件通道。
筹款活动
现在,场景中的任何对象都可以引用事件通道,并使用RaiseEvent方法调用事件。例如,请看下面带有TriggerEvent方法的 MonoBehaviour 示例。
在 "检查器 "中,需要将 ScriptableObject 资产分配给m_EventChannel字段。当有东西调用TriggerEvent 时,事件就会执行。任何正在收听的内容都会收到通知。
这种机制为游戏应用程序增加了许多互动性。每个模块或系统都会引发一个事件(例如,输入系统记录按键,小球与墙壁碰撞等)。作为回应,其他东西也会对此做出反应。
public class EventRaiser: MonoBehaviour
{
[SerializeField]
private VoidEventChannelSO m_EventChannel;
public void TriggerEvent()
{
m_EventChannel.RaiseEvent();
}
}

游戏管理器会监听某些事件频道,并在其他频道上进行广播。
聆听活动
要设置监听器,MonoBehaviour 或其他组件需要订阅事件通道的OnEventRaised事件。通常情况下,这发生在启用中,如下面的示例。
当事件通道引发事件时,HandleEvent 方法会响应运行。该机制可用于各种目的,如播放声音或效果、修改设置等,具体取决于事件的上下文。
在PaddleBallSO项目中,我们是这样设置主游戏循环的。GameManager 监听一组事件通道,然后在另一组事件通道上进行广播。这样,不同的系统就可以相互发送信息,而不一定有直接的依赖关系。
最后,在OnDisable方法中取消订阅OnEventRaised事件,以防止错误或内存泄漏。
public class EventListener: MonoBehaviour
{
[SerializeField]
private VoidEventChannelSO m_EventChannel;
private void OnEnable()
{
m_EventChannel.OnEventRaised += HandleEvent;
}
private void OnDisable()
{
m_EventChannel.OnEventRaised -= HandleEvent;
}
private void HandleEvent()
{
Debug.Log("Event received");
}
}

在检查器中设置无代码交互功能
添加无代码监听器
如果你正在与设计师合作,你可能想为他们提供一个可以监听事件的预配置通用脚本。这将使他们能够在没有程序员的情况下创建游戏互动。
VoidEventChannelListener就是一个例子。当该组件接收到来自事件通道的信号时,会引发一个 UnityEvent。只需将 VoidEventChannelListener 添加到 GameObject,然后设置事件通道和 UnityEvent 逻辑即可。
这样,设计人员只需在检查器中进行一些设置,就能设计出事件驱动逻辑的原型。
例如,GameOverSounds预制件会监听GameOver_SO事件通道。一旦接收到该事件,它就会通过m_ResponseUnityEvent 在给定的 AudioSource 上播放声音。
VoidEventChannelListener 类还包含一个有用的延迟,用于调整每次响应的时间。
只要稍加练习,就能轻松在不同系统和模块之间建立互动。

标记为发送和接收的事件通道
活动渠道如何提供帮助
由于事件通道存在于项目层面,因此可在全球范围内访问。这样,它们就可以连接场景层次结构中的任何对象,并在场景加载过程中持续存在。
任何对象都可以充当广播者或监听者,这只是如何与事件通道接口的问题。这为您发送信息提供了很大的灵活性。
请注意:在 "检查器 "中说明通道是用于发送还是接收是一种好的做法。使用HeaderAttribute(标题属性)来实现这一功能。
在项目级别使用事件的一个好处是,它们通常可以取代对单例的需求。活动渠道是全球通用的,因此可以连接任何事物和任何事物。让它们驱动相机、任务、健康和成就等游戏系统,而不会产生不必要的依赖性。
此外,由于基于事件的架构只在需要时执行,因此比 MonoBehaviour 的更新方法更优化。
基本事件的函数签名
这个 VoidEventChannelSO 类只适用于不需要任何参数的事件。通常情况下,提出的事件需要额外的数据负载才有意义。
例如,如果您要发送一个在健康系统中造成伤害的事件,您可能需要为目标传递一个值、发送多少伤害、伤害类型等。
您可以更改基本事件的函数签名,使事件通道更适合该事件。项目为此定义了一个 GenericEventChannelSO。请看下面的示例。
这是一个抽象类,只有一个通用参数。您将从中衍生出其他活动渠道。这些参数可以传递单个参数,如 float、int 或 bool。
与 VoidEventChannelSO 一样,GenericEventChannelSO 也有一个名为 OnEventRaised 的 UnityAction。不过,这次的操作带有一个 T 类型的参数。
外部对象将调用相应的公共 RaiseEvent 方法。如果事件有侦听器,则会在传递给定参数时执行。
public abstract class GenericEventChannelSO<T>: DescriptionSO
{
public UnityAction<T> OnEventRaised;
public void RaiseEvent(T parameter)
{
if (OnEventRaised == null)
return;
OnEventRaised.Invoke(parameter);
}
}
创建具体的事件通道
现在,您只需从 GenericEventChannelSO 派生具体的事件通道,并填写 T 的值。
除了常用的 CreateAssetMenu 属性外,不需要任何明确的实现细节。
创建一个携带浮点数的事件通道FloatEventChannelSO 非常简单。请看下面的代码示例。
就是这么简单!使用此工作流可为 BoolEventChannelSO、IntEventChannelSO 等创建其他工作流。
如果需要一个以上的参数作为有效载荷,可根据需要定义额外的泛型类(如 GenericEventChannelSO<T,U>、GenericEventChannelSO<T,U,V> 等)。
[CreateAssetMenu(menuName = "Events/Float EventChannel", fileName = "FloatEventChannel")]
public class FloatEventChannelSO : GenericEventChannelSO<float> {}

当球射入球门时的先后顺序
将所有内容整合在一起
我们的想法是将应用程序分成更小、更模块化的部分。围绕这些部分建立明确的界限,可以防止这些部分与依赖关系相互交织,有助于避免出现细枝末节的代码。
对外界物体缺乏直接了解的组件无法操纵它们不应该操纵的东西。相反,他们不得不通过事件渠道发送和接收信息。
如果您追踪一下桨球游戏的一个小序列,就会知道它是如何工作的。例如,让我们想象一下当一个球与一个 ScoreGoal 相撞时会发生什么:
ScoreGoal组件注册碰撞。检测到球后,它会在GoalHit_SO事件通道上引发一个事件。这将传递得分球员的球员 ID。
事件通道会通知GameManager,GameManager 会响应引发另一个名为PointsScored_SO 的事件通道。这也会传递播放器 ID。
该通道会通知ScoreManager,后者会增加分数(存储在一个单独的对象中)并更新用户界面组件。然后,它会通过ScoreManagerUpdated_SO事件通道传递两名球员的得分。
作为回应,ScoreObjective_SO目标会检查是否有一名玩家达到了目标分数。
如果达到获胜条件,游戏结束。否则,"游戏管理器 "将重置回合,球将重新进入游戏。
乍一看,将分数值增加 1 分似乎要做很多额外的工作。不过,我们的目的是要把所有涉及到的部分都分离出来:球"、"得分管理器"、"游戏管理器"、"目标管理器 "等。
应用程序的每个部分都有一定的自主权,这使得每个部分都更容易测试。添加新系统无需破坏现有逻辑。事实上,原始的游戏玩法可以完全无视它们。
想象一下,您想在评分过程中添加声音和动画等辅助效果。您可以创建新的组件来监听正确的事件并做出适当的响应。即使在添加新系统时,底层逻辑和游戏流程也不会受到干扰。
请记住,SOLID 编程的口号是 "开放供扩展,封闭供修改"。您希望无需更改现有代码就能为软件添加新功能。使用这样的事件通道具有可扩展性。

编辑器脚本可以帮助调试事件。
调试事件
事件驱动架构便于调试和维护。无论是使用Unity 测试框架编写自动单元测试,还是进行非正式的故障排除,小部件都更容易测试。这样,您就可以专注于某个特定问题,并单独进行测试。
自定义编辑器脚本可以在这方面提供帮助。PaddleBallSO演示了一些工具,它们有助于在使用事件通道时跟踪应用程序的流程:
- PaddleBallSO项目中的大多数事件通道都会在检查器中显示侦听器列表。单击每个监听器的名称,使其在层次结构中突出显示。
- 自定义RaiseEvent按钮可随意调用模拟事件(如果携带有效载荷,则使用默认值 T)。在程序运行时,只需单击即可手动触发。
排除事件通道故障时,请选择可脚本对象资产。根据需要手动测试事件。检查员可以引导您查看可能在监听的对象。选择要详细检查的监听器。
如果用 HeaderAttritute 标记了事件通道,就可以回溯几个事件,了解逻辑流程。

更多可脚本对象资源
我们希望事件渠道和事件驱动型架构能为您的新项目和即将开展的项目带来益处。
在我们的技术电子书中阅读更多有关使用 ScriptableObjects 的设计模式的信息、 使用脚本对象在 Unity 中创建模块化游戏架构.您还可以在以下文章中了解有关常见 Unity 开发设计模式的更多信息 使用游戏编程模式提升代码水平.