大家好。

最近我想与大家分享我的项目的设计转变。这很多的独自开发的人,我的项目通常会以干净的方式开始,最后会变成一个紧密耦合的依赖关系。这种经典的例子是:Player.cs会损伤,这需要更新UI,所以它会调用UIManager.Instance.UpdateHealth()

突然之间,你的玩家预设会被强制绑定到UI上。你加载一个空白的测试场景来调整移动,但游戏会抛出空引用异常,因为UI管理器丢失。

我查看了纯粹的ECS(实体-组件-系统)技术来解决这个问题,但truthfully来说,boilerplate和学习曲线对我来说太难了,所以我转向使用ScriptableObject驱动的事件通道。

这不是一个新的概念(Ryan Hipple在2017年的Unite演讲中讲解了基础),但我想与大家分享我如何解决SO事件的最大缺点: 全局噪声

设置

核心设定非常简单:

  1. GameEvent (ScriptableObject)作为通道。
  2. GameEventListener (MonoBehaviour)坐在一个预设上,监听SO,并触发UnityEvents。
  3. 发送者只会调用myEvent.Raise(this)。它不知道谁在监听。

问题:全局事件噪音

SO事件的直接问题是它是全局的。如果你在场景中有10个哥布林,哥布林A损伤的时候,它会触发OnTakeDamageSO事件。但是哥布林B的UI也在监听这个同一个SO事件。突然之间,每个哥布林都显示红色。

大多数人解决这个问题通过在运行时创建每个单独的敌人的SO实例。这是一个内存管理的噩梦。

解决方案:局部层次结构滤波

取代的是实例化新的SO。在全局通道上,我添加了一个空间过滤器到监听器。

当事件被触发时,广播者会将自身作为senderpublic void Raise(Component sender)

GameEventListener上,我添加了一个简单的开关:onlyFromThisObject。如果这个开关为true,监听器检查sender是否是本地预设层次结构的一部分:

if (binding.onlyFromThisObject) {
        if (filterRoot == null || sender == null || (sender.transform != filterRoot && !sender.transform.IsChildOf(filterRoot))) {
            continue; // 忽略全局噪音,这个事件不是给我们看的
        }
    }
    binding.response?.Invoke(sender);

为什么这个工作流程可以实现可扩展性:

  1. 零硬依赖: 战斗模块不需要知道UI存在。你可以删除UI层级结构,但不会有任何问题。
  2. 设计师友好: 你可以拖放一个OnDeath事件到一个UnityEvent位置来触发音效和粒子 효과,而不需要改变C#脚本。
  3. 预设隔离: gracias于局部滤波,哥布林预设是独立的。你可以在场景中添加50个,它们只会响应它们自己的内部事件,尽管它们使用着同一个全局SO通道。

局限性(诚实的说): 这不是一个银弹。跟踪事件可能会很麻烦,因为你无法简单的F12 (跳转到定义)来看谁在监听这个事件。你最终需要写一个自定义的编辑窗口来跟踪所有的监听器,如果项目变得过于庞大,这一点变得更加重要。

我清理了核心脚本(Event, Listener,和ComponentEvent),并将它们上传到github下MIT许可证下。如果你正在努力解决紧密耦合的代码还是单例地狱,请自由地在你的项目中加入这个系统。

项目和设置可视化指南在这里:

https://github.com/MorfiusMatie/Unity-SO-Event-System

渴望听听其他无名开发者们是如何处理全局和局部事件问题避免使用完全的ECS。