《代号-奥罗拉岛》开发日志04 用Entitas(ECS框架)重构项目
写在前面大概在2月份,我阐述了自己从游戏策划、游戏设计的角度,对于面向组件的游戏开发方式的一些观点。
之后我就开始致力于研究、实践这一方法,并开始制作独立游戏《代号-奥罗拉岛》。
起初我选择的游戏引擎是Godot,但在某些方面遇到了一些困难(主要是tilemap),可喜的是这些问题在godot 4.0 版本中都得到了解决。但在等待4.0正式发布前,我决定更换为Unity引擎。
当然,选择Unity引擎另一个原因是希望能够更容易找到一起开发的小伙伴。
关注Entitas框架已经是多年前的事情了,一直很喜欢Entitas 的设计哲学。当然选择Entitas的另一个更重要的原因就是后续可以方便移植到Godot中。
Entitas 如何与 Unity 解耦
基于以上原因(方便移植到godot)所以我在最开始就考虑Entitas 框架如何与Unity 解耦。在官方的github wiki上我看到了这篇文章:
文章作者的思路是通过Interface控制反转的方式,将Unity的API抽象为供给Entitas调用的Service,如下图所示:
Entitas 与 Unity的解耦模型
当然,得益于Entitas的代码生成和Event功能,Unity可以订阅Component 的修改并相应做到View层的改变。
这方面的架构模型类似于MVC,由Entitas 负责 Model 和Controller 部分,而Unity引擎负责View部分。或者说是CS模型,Unity只关注表现层面,Entitas负责数据模拟,这样双方都可以不关心彼此(或者说至少做到Entitas不关心表现层面的东西。)
下面来讲一些开发实践中的典型问题。
抽象Service处理玩家输入
在官方的Match-One 案例中,采用的是将Input抽象为Component并在单独的Context中处理,然后通过一个InputSystem反应系统来处理具体的输入逻辑。这样做毫无疑问将Entitas和Unity在了一起。
上面讲述通过Interface解耦Unity和Entitas的文章中对这种情况有很详细的解决办法。这里不再赘述。
将Input抽象为Service并保存在单例组件InputServiceComponent中,这是一个独立的ServiceContext,当然这只是一种实践方式,也可以采用依赖注入的方式而不是Component,本质思想不变。
但我很快遇到了新的问题,那就是依赖于物理的移动。在Unity引擎中通过刚体组件来判断角色是否和场景中的物体发生关系。并作出合理的移动判断。这该如何处理呢?
抽象Controller处理依赖于Unity的逻辑部分
如果Unity只作为表现层,而包括物理在内的逻辑校验都在Entitas中处理的话,就不需要讨论Controller这部分了。但不幸的是,类似物理、碰撞等内容依然依赖于Unity。这导致我进一步抽象出Controller,同样是通过Interface。以物理组件为例:
IphysiceController.cs
然后是Unity中的实现:
需要继承MoniBehaviour
所有的Controller都需要挂在GameObject上,因此需要继承MonoBehaviour
这个组件目前只是需要实现Position属性。然后我们在Entitas中通过componet持有这个Controller:
PhysicsComponent组件
最后,通过一个SyncPositionSystem 来处理位置同步相关的逻辑,这也是一个典型的ECS中的系统,首先收集感兴趣的Entity:这些Entity同时包含位置组件、物理组件和速度组件。之后遍历所有满足条件的Entity,将其位置信息和物理组件中的位置信息同步。
SyncPositionSystem
截止到目前,似乎一切又很完美了,但当我们要处理Collider的时候,似乎又遇到了问题
通过接口事件处理碰撞
但到目前位置,我依然不能确认这种方式是否是最佳实践。欢迎有不同意见的读者朋友交流。
按照上面的思路,对于需要碰撞的组件,我们添加IColliderController和 持有它的 ColliderController组件。然后我们在Unity中实现这个接口:
ColliderController
这里面比较特殊的就是Action,当碰撞发生的时候或者结束的时候,Unity能够捕捉到Enter和Exit事件。然后调用对应的Action。于是我们可以在Entitas处理回调方法:
ColliderInputSystem
我有一个ColliderSystem,这是一个反应系统(ReactiveSystem),这个系统当ColliderComponent被添加的时候,就开始监听Trigger Enter 和 Exit的事件。然后可以进行下一步处理。
Entitas 内部代码如何组织
Entitas的作者在谈如何组织Entitas和Unity的代码时,绘制了下图:
ECS Game Architecture with Unity and Entitas
我认真看了作者和其他开发者之间的讨论,特别是关于Entitas内部的代码应该如何组织。
这里有必要讨论一下Context,对于Entitas中这一定义,我们应该怎么区分,当然,不只是制作层面,对于内存调用的问题,也包括代码架构方面,我的分层不是对于游戏中对象的切片,而是对于宏观上模块之间的切片。游戏内玩家能看到的componet和Entity全部在GameContext中,而除此之外的Context,则是一些更高级别的抽象,这里重点包括ServiceContext中持有着Entitas需要的各种Service实现。以及CommandContext中对于“命令”的抽象。
ControllerComponent 和ServiceComponent 通常是依赖于外部,需要Entitas每帧调用的。或者通过Action事件方式调用。但当感兴趣的事件发生时,相应的System如何操作。我这里的解决办法是,创建Command,以移动功能为例:
EmitInputSystem
首先是一个逐帧调用的移动输入判断,如果输入判断成功,则创建一个Command,这里是创建MoveCommand,通过对CommandContexts的扩展函数实现:
CreateMoveCommandEntity
在Entitas中需要大量的Context和Entity的扩展函数来完成一些公共代码的编写,其中最常见的就是创建某某实体。一个CommandEntity通常包括一个Owner,命令的发起者,然后根据命令的差别再包含一些自定义属性,这里是速度组件。
这里需要特别说明一下Owner组件,Entitas特别说明了不推荐对于Entity的直接引用,原因大概涉及到Entity的销毁、对象池之类的底层实现。但如果只是短暂的持有,开发人员明确其调用的时机的话则可以。因为如果用ID方式调用Entity必然有效率上的差异,这需要开发者自己取舍。
然后就是Entitas特有的ReactiveSystem,这是一类特殊的系统,这类系统只观察符合条件的Entity,在进行某个component的增删改(改本质上也是删增复合操作)操作时,触发Execute方法,这需要和IExecuteSystem系统区分开,并不是每帧调用。
于是我们顺理成章地有RespMoveSystem响应移动的系统,这个系统会将状态、数据的改变实际作用在GameEntity上:
RespMoveSystem
RespMoveSystem只是将速度组件添加在Owner对应的GameEntity上,一些更复杂的command可能还要在这里进行一些逻辑判断,比如伤害命令要判断目标GameEntity是否为“无敌”状态(也就是带有“无敌”组件)
之后,我们手动删除这个命令,或者这个命令Entity本身就是自销毁的。
抽象出Command的好处很多,比如后续一些AiService可以创造command而不是直接修改GameComponent、这使得Command可复用。并且在需要的时候可以将其序列化,方便制作类似录像回放等功能。
最后,便是GameContext中的一些逻辑处理,比如位置同步等,但做到了Command和GameSystem之间的逻辑分离,一切看起来更清晰了。
我这里并没有在添加InputAction层,因为InputAction都包含在Service和Controller中,于是我的代码结构看起来像这样:
我所理解的Entitas框架架构
尾声
ECS模型作为相对新颖的游戏开发模型,不同于MVC或其他更通用的模型,目前在网络上的学习资料依然不多。因此本文所阐述的内容只代表作者开发工程中的一些感悟,并不能作为ECS模式开发的最佳实践。在此抛砖引玉,欢迎大家友好交流。
后续依然会花更多精力用来研究ECS开发和更“活泼”的游戏AI设计,按照计划,下一篇文章应该会带来我对于GOAP相关研究的实践文章,并且会考虑如何将GOAP与Entitas框架结合起来使用。
我们下一篇文章再会。
页:
[1]