浅谈实体组件模式
前言
本文说一下在传统设计模式下的实体组件模式,它的设计模式和ECS架构里面的设计有很大的差异。但这里不会就差异进行展开,而是着重说明前者的设计理念,拓展以及这种模式下可能出现的问题点。当然,个人见解,有问题欢迎指出。
组合优于继承
首先要明确一点在面向对象的开发中,存在一个及设计原则便是“组合优于继承”。
为了了解到这个原则,我们可以举个例子来说明一下。
有一天策划说要做三种类型的哥布林,分别是战士哥布林,法师哥布林,和弓手哥布林。如果按照面向对象的思维,我们可能的处理方式是:书写一个哥布林基类,然后创建三个派生类,分别是战士哥布林,法师哥布林,和弓手哥布林。根据每个功能类型不一致,去书写各自的功能,比如战士哥布林只能近战,法师哥布林可以释放法术攻击,弓手哥布林可以进行远射击。这样似乎能完美的解决需求,但是如果后面版本迭代了,策划大人又发话了,再设计一个新的功能,战士哥布林可以进行魔法功能,法师哥布林可以发射远程魔法箭。那么作为卑微的程序,按照传统的面向对象的思维,是不是就需要再创建一个魔法战士哥布林类型以及一个魔弓手哥布林类型?显然这样做需求也得到了满足。但是后面吊策划又发话了,要设计各种各样的哥布林,那些元素和属性要能够自由搭配,那如果还是按照面向对象继承的思维,那得把所有的情况全部列举出来,然后挨个去写派生类,这还不是最难受的,如果再涉及到任何这些关联内容的功能修改,这也意味着你要去维护每一个你写的类型,这意味着极大的维护成本和超多的工作量。
而如果使用组合的形式,我们可以声明三个组件,分别是近战组件,远程攻击组件,魔法攻击组件,通过自由搭配添加,只有哥布林挂在了近战组件,它就有近战的功能,挂载了魔法组件,就可以成为法战士哥布林。这样不仅方便后续拓展,维护起来也只用在意对应组件的功能,而不用去维护所有哥布林的功能。
整个过程也符合开发的直觉和流程————并不是一开始就把所有的功能都写好了,而是逐步的添加,逐步的实现功能。
什么是实体组件模式
偏传统模式的实体组件与ECS框架里面里面的实体组件虽然些许存在相似的地方,但却是完全不同的概念,这里不拓展展开。先说一下传统开发上的实体组件模式吧,不过要注意一下,在不同的框架中,实体组件模式的定义以及实现方式这些都是可能存在一定的差异的,皆是为了更好的实现需求,接下来要展开讲的也只是其中的一种形式,但万变不离其宗,在了解其中一种之后,再遇到也只需要类比推理即可。

实体
实体在设计层面实体只是一个概念上的定义,是单一对象数据的载体,是一系列组件的集合。而它可以用来表示游戏中的各类物体,比如玩家或者敌人;有功能的一些道具,甚至一些比较抽象的物体。
在脚本设计上,实体可以是一个基类,它主要是用来存储对应的数据和组件的。它是应该是支持继承的,但是它的继承主要是用于拓展其数据和组件。比如,一个Entity基类,它包含一些基本和通用的信息,我们如果想要一个敌人的实体,我们就可以继承这个基类,然后添加一些敌人独有的属性和组件;如果想要一个NPC的实体,我们同样可以继承这个基类,然后添加一些NPC独有的属性和组件。
整个过程中,无论是敌人有自己的功能和身份标识,还是NPC有自己的特性这些,其实综合来看就是数据和组件的拓展,这也是为什么我说这种情况下继承的意义在于能够根据我们想要的情况去拓展对应的数据和组件。
这里有个要点,关于实体继承和组件怎么去搭配更好其实是有很多种方式的,这个后面会重点说一下,这里先按下不表。
那么实体是否需要处理逻辑呢?
在一些地方实体并不需要处理逻辑,但个人的看法是实体其实是可以处理一定程度的逻辑的。除了基本的对数据和组件的管理逻辑之外,对于一些比较通用和基本的逻辑,在我看来是可以在实体里面进行处理的,通过这些处理也可以在一定程度上提高效率。
但是要注意的是,实体处理逻辑的范围不应该太大,不要把它搞得太复杂,否则会影响到实体的拓展和复用,以及尽量避免把实体的功能和特定组件的逻辑耦合在一起。
后面要说的动作事件便是功能逻辑整合的一种方式。
组件
一个组件就是包含对应数据和逻辑的单一功能。
比如移动,HUD,巡逻这些功能都可以组件化,我们给一个实体挂载了一个这样的组件,这就意味着它具备了这个功能。也因此单个组件里面的数据和逻辑都应该服务且只服务于这个功能,应该尽量避免耦合到其他组件的功能。
组件的生命周期和更新形式应该如何试下?
组件的生命周期可以由实体进行传递和管理,通过实体来进行统一管理,以此来确保单个实体下对应的刷新顺序和逻辑的执行,毕竟实体也是通过上层更新进行传递,这样的从上至下可以确定一定的规范性。
还有一种形式是以组件为标准进行更新,在一些情况下,保证被实体使用的所有组件按照一个严格规定的顺序来更新,比一直追踪哪些实体包含那些组件来更新要更有效。就是搞一个组件系统,让组件系统将会向它所管理的所有组件实例转发更新消息,而不用考虑这些组件实例绑定的是那些实体。当然这也只是提一嘴,不过多展开。
组件是否需要处理逻辑呢?
一般来讲,组件是需要处理逻辑的,无论是数据的处理还是对应功能逻辑的封装这些是必定要做的,并且在需要的时候,也是可以在对应的更新方法中执行其更新逻辑的。
动作事件
动作事件层,也就是上面的Action层,这里给它的定义是:关联到多种模块的功能块。
实体组件模式为什么还要引入这么一个动作事件层呢?
因为在实际开发中,是存在需要耦合各个组件的功能从而去实现一个整体的功能的情况的,而当这些功能可能被多次复用的时候,我们可能就希望能够有这样一个“功能块”去执行这样的一个整体功能,这就是动作事件的意义所在。
比如,我们可能希望有一个“坐下”的动作,这个动作包含了多个组件的功能,比如移动组件,动画组件,巡路组件,这些组件的功能都需要一起执行才能实现这个动作。我们通过封装这个功能为一个“动作事件”,然后在需要的时候去执行这个动作事件,这样就能实现这个动作的执行。
它并不关心是哪个实体调用的,也不关心这个实体的任何特性,只关心这个动作的执行。这样做的好处在于给出了一个综合动作的执行位置,规范了开发细节,做到了一定层度的解耦,也方便功能的拓展和维护。
实体和组件的配合形式
- 形式1:基层实体挂载通用组件,派生层实体挂载自定义特定组件
- 形式2:基层实体不挂载任何组件,全部根据派生层实体需求挂载自定义特定组件
在形式1的情况下,基类里面可以包含通用的组件,各自派生类可以根据自己的需求挂载自定义的组件。这样做法的好处在于减少了代码的冗余度,且方便维护。
但是也会衍生出一些思考或者问题:
真的有所谓的通用组件吗?
你可能会觉得这个问题问的很抽象,但事实确实是这样,在一些情况下是不存在通用的组件的,比如物理组件也并不是所有实体都需要,健康组件也是同理。在需求如此的情况下,我们并不能说一定存在一个通用的组件,大不了存在比如HUD,游戏里面可能90%的实体都有,但是可能还是会存在10%的实体就是没有显示的需求,而如果放在了基类实体里面,那些不需要的实体就不得不挂载这个组件,并处理其逻辑,让其不影响自身的功能,这就造成了不必要的浪费。
在形式2的情况下,基类实体不挂载任何组件,全部由派生类实体挂载自定义的组件,这样做的好处可以解决上面的问题,但是稍微想一下就知道,每个类型的实体都要去挨个去处理每个组件,这其实是为了准确度牺牲了开发效率。
但是还是可以给出一些通用的建议:
实体的分层一定要尽可能的细致一些,特别是在决定基层的时候,如果采用了形式1,则这一点至关重要,不然可能会让后面的派生类执行过多的冗余代码,造成严重的浪费。
实体里面处理组件的逻辑应当更加少,特别是采用了形式2的情况下,如果在不同类型实体里面去处理了相同类型组件的逻辑,如果组件的内容得到了修改,有可能就会导致“牵一发而动全身”的情况,若是耦合了起来,则情况更是糟糕,这是要避免的情况。
为什么不全部替换为ECS
“现在还没有用ECS的都是对技术不上进的”,有幸在某个地方听到过这样一句话。
这句话听着很抽象,毕竟还是要根据是根据实际情况实际分析的。
传统的实体组件模式更符合OOP的思想,使用起来更加灵活,容易上手且开发便捷,它更加适合中小型项目的开发。相反,ECS的对数据要求进行严格的规划,模块之间耦合更低,能应对处理大量实体的情景,但随之而来的是架构的复杂性提升。拿ECS去做一些体量和功能都较小的项目,其对大场景的优化设计好拓展性反而成为了最冗余的部分。
还是那句话——没有最好的,只有最适合的。