使用ECS框架设计游戏

前言

虽然教程在Github上很多,但是多注重于如何用,比较少关于为什么这么用,也许作者默认使用者对框架的思想很熟悉,而且教程都是英文的。。。

框架介绍:

Entitas和Unity提供的脚本控制GameObject的思路有一定的相似性

最大的区别,也是根本性的区别是,挂载在GameObject上的MonoBehaviour,他是数据和行为的集合,也就是数据和逻辑写在同一处,并且每一个组件都有Awake,update…这种设计让我们可以很方便的和Unity引擎做交互。

ECS的理念是Data驱动逻辑,驱动引擎,所以为了达成这个理念,整个框架基本上就是基于事件流的形式运作,即C的变化以E为载体从而抵达合适的S,而游戏的过程也是从数据和规则出发进行的。

夸张的说,所有的Entity都在一个池子里,可以轻松的按照特定的规则把你需要的Entity获取到,比如说带有PositionComponent和MoveableComponent的Entity。但是由于这种特性,在过滤的时候需要注意你的限定范围恰到好处。类似使用Entitas管理了所有的依赖项。

C ===> Componet,实体组件,只有数据,没有任何方法

E ===> Enity,实体,用来装载各种组件。

S ===> 用于响应多种C组成的Entity,比如id+position完成对PositionSystem的响应,只有方法,没有数据

框架的运作过程可以简单描述成:定义不同的Enity,经过ReactiveSystem高解耦性的完成对特定事件的响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
+------------------+
| Context |
|------------------|
| e e | +-----------+
| e e---|----> | Entity |
| e e | |-----------|
| e e e | | Component |
| e e | | | +-----------+
| e e | | Component-|----> | Component |
| e e e | | | |-----------|
| e e e | | Component | | Data |
+------------------+ +-----------+ +-----------+
|
|
| +-------------+ Groups:
| | e | Subsets of entities in the context
| | e e | for blazing fast querying
+---> | +------------+
| e | | |
| e | e | e |
+--------|----+ e |
| e |
| e e |
+------------+

从代码来看,核心的就两大块:

1、Entitas.Systems: 框架的核心,组织整个框架功能运行,各种S的注册,调用,分发,注销,关闭….长这样:

  • public class Systems : IInitializeSystem, IExecuteSystem, ICleanupSystem, ITearDownSystem

2、Contexts: 以前的版本叫 pool,方便Systems运作的容器,可以认为是Entity池。

池里面接而细分了各种小池用来满足oop思维上的需要,比如游戏逻辑上的Game,比如经过服务器的判定,xxx对xxx造成了多少伤害,被攻击者扣血,并且产生飙血特效,攻击者技能进入冷却,MP减少,获得了一个增益效果。这个过程可以在代码中这样体现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...接收到服务器判定结果,创建一个Entity...
var e = contexts.game.CreateEntity();
e.AddBattleInfoComponent("攻击者","被攻击者","释放的技能",damage);
...上面创建的Entity会响应到相应的ReactiveSystem
public class BattleShow:ReactiveSystem<GameEntity>{
...collector...
...Fliter...筛选需要的Entity
overried Excute(){
UI显示战斗信息
更新双方状态,角色属性,buff或者debuff
生成技能特效
}
...
}

输入响应方面的Input,比如按钮点击,键盘输入…

具体怎么用呢?如ECS所述,C,S分离,S注重方法定义;C注重字段定义。通过ECS的特性可以让我们从繁琐的逻辑交互解放出来,写出清晰,简洁的代码,使用ECS框架会产生很多相似的代码,所以借助了代码生成器来自动生成一些定义组件和事件绑定的代码。

这样我们就从MonoBehaviour解脱出来了,多人协作只要按照ECS框架进行,就能写出高性能,强解耦合的代码,从另一方面说,如果不按照ECS框架来走,新加的代码寸步难行。

定义C:

实现IComponent接口,给类加上特性标签,是Game,还是Input。

组件字段还可以加上[EntityIndex]特性,0.47.7版本的ECS需要using Entitas.CodeGeneration.Attributes,在相应的Contexts,自动生成了快速访问的方法,比如这里就是对GameEntity下的PlayerId设立了一个集合

1
2
3
4
5
6
7
8
using Entitas;
using Entitas.CodeGeneration.Attributes;
[Game]
public class PlayerIdComponent : IComponent
{
[EntityIndex]
public string playerId;
}

可以通过context中的game池快速获取所有创建了的带有PlayerIdComponent组件的Entity,及对应的字段,以HashSet结构返回。

1
2
3
4
5
public static class ContextsExtensions {
public static System.Collections.Generic.HashSet<GameEntity> GetEntitiesWithPlayerId(this GameContext context, string playerId) {
return ((Entitas.EntityIndex<GameEntity, string>)context.GetEntityIndex(Contexts.PlayerId)).GetEntities(playerId);
}
}

如果再加上[Unique] 特性修饰,说明该组件在当前Contexts下只能存在一个

在entity中通过hasXXX可以判断组件是否存在,entity.xxx定位到该组件

代码生成


根据定义的C,生成代码。

定义S

IInitializeSystem 做一些初始化的操作。

Reactive Systems,基于事件的响应,通过实现各种Reactive Systems,来达到响应,比如在网络同步的时候使用Reactive Systems会感觉异常的轻松。

*
当有entity创建时,针对不同entity的配置,加入不同的注册队列,在下一个循环帧开启时,响应队列中的内容,然后释放。
每个继承Reactive Systems的类主要包含了collects,filters,executes三个部分
1.Collector
记录了每次entities的变化,在entities remove之前一直被保存
2.Filter
再次验证你需要的Entities,并不是所有时候都要用到
3.Execute
只会执行发生变化,并且经过筛选后的entities,只会在Entities发生改变才会execute
看我们继承的ReactiveSystem抽象类源码就可以知道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public abstract class ReactiveSystem<TEntity> : IReactiveSystem where TEntity : class, IEntity, new() {
//构造函数
protected ReactiveSystem(IContext<TEntity> context) {
_collector = GetTrigger(context); //保持集合
_buffer = new List<TEntity>();//Entity数组
}
...
//需要实现的三个抽象类
//Collector
protected abstract Collector<TEntity> GetTrigger(IContext<TEntity> context);
//过滤规则
protected abstract bool Filter(TEntity entity);
//最终满足要求的Entites
protected abstract void Execute(List<TEntity> entities);
//在update或者fixedUpdate中调用
public void Execute() {
if(_collector.collectedEntities.Count != 0) {
foreach(var e in _collector.collectedEntities) {
if(Filter(e)) {
e.Retain(this);
_buffer.Add(e);
}
}
_collector.ClearCollectedEntities();
if(_buffer.Count != 0) {
Execute(_buffer);
for(int i = 0; i < _buffer.Count; i++) {
_buffer[i].Release(this);
}
_buffer.Clear();
}
}
}
...
}

Execute Systems在Unity引擎的update或者fixedupdate中循环调用

Show Case

1、减少Entity的创建

如果定义一个组件

1
2
[Game]
public MoveableComponent : IComponent{}

自动生成的代码允许我们使用game.isMoveable设置

如果给在一个Enity上进行下面的设置

1
2
game.isMoveable = false;
game.isMoveable = true;

可以起到类似刷新,重新创建一个Entity的效果,进而可以再一次响应对应的ReactiveSystem,这样可以减少Entity的Create。

相应的,也可以通过ReplaceXXX()来起到类似的效能。

2、Entity的销毁

有时候为了不创建过多的Entity,比如经过服务器的结算,xxx道具被成功拍下,这一个条消息需要①响应数据库的更改,②拍卖行UI系统的界面更新,③邮箱有一条成功交易的记录,并且收到了拍卖行发过来的道具。④玩家金币数量减少….等等

在这么一个过程不太可能取创建四个Entity,一个对应数据库,一个对应UI,一个对应邮箱,一个对应金币数量更新。

此时可以做的是,创建一个Entity,上面挂上响应不同系统的组件,当一个系统完成了响应后,不要立即销毁,如果立即销毁会导致剩下的三个系统无法进行响应,所以可以给他标记上是否需要销毁,在这一帧完成之后,即所有的系统都响应完成,此时就可以销毁了。

还有一种办法是,让所有的响应系统实现ICleanUpSystem,在CleanUp里面完成对Entity的销毁,但是这种处理方法,需要在在系统中保存下这一帧所有的处理的Entity,在CleanUp内做销毁。

初始化一些配置

在Unity中创建一个Mono脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class GameController : MonoBehaviour
{
private Systems _systems; //
private Contexts _contexts;
private void Awake()
{
_contexts = Contexts.sharedInstance;
_systems = CreateSystems(_contexts);
_systems.Initialize();
}
private void FixedUpdate()
{
_systems.Execute();
_systems.Cleanup();
}
private void OnDestroy()
{
_systems.TearDown();
}
private Systems CreateSystems(Contexts contexts)
{
_systems = new Systems();
return _systems.Add(new TickUpdateSystem());
//return new Feature("ECSUI").Add(new TickUpdateSystem()); //使用调试面板
}
}

辅助性的类

根据需要可以使用EntityLinkExtension构造一个EntityLink关联GameObject和Enity、Context,比如有一些非指向性技能,需要进行碰撞检测的时候就有这个需要了,我需要获取碰撞到的玩家,根据玩家信息,做一些伤害计算,击中头部伤害翻倍之类。

1
2
3
4
5
6
7
8
9
namespace Entitas.Unity
{
public static class EntityLinkExtension
{
public static EntityLink GetEntityLink(this GameObject gameObject);
public static EntityLink Link(this GameObject gameObject, IEntity entity, IContext context);
public static void Unlink(this GameObject gameObject);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
namespace Entitas.Unity
{
public class EntityLink : MonoBehaviour
{
public EntityLink();
public IEntity entity { get; }
public IContext context { get; }
public void Link(IEntity entity, IContext context);
public override string ToString();
public void Unlink();
}
}

IGroup

Group是包含了特定Component的Entities集合,从这个集合中能够在更小范围内的定位合适的Entity

当单纯的Collector,和Filter不能满足需求时,可以使用Group,比如说,当点击一个Buton后,需要响应一连串的事件,这个时候会构造一个接口,让需要响应这个Button的类去实现这个接口,当我们点击Button反馈到ReactiveSystem的时候,就去遍历所有实现了这个接口的组。

观察ECS框架的代码就可以看到,里面充斥着对集合的运用,Group,Collector,EntityIndex返回的HashSet,其目的就在于给出合适的数据后,能够得到我们想要的东西,某一个或者一组Entity,特定的System等等。

其实就是根据Entity上的Component的一个组合编号,通常可以用这些编号来快速定位到需要的Entity
比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
readonly IGroup<GameEntity> _group;
//初始化时就把需要的实体筛选出来
public MoveSystem(Contexts contexts) {
_group = contexts.game.GetGroup(Matcher<GameEntity>.AllOf(GameMatcher.Move, GameMatcher.Position));
}
//每一帧调用
public void Execute() {
foreach (var e in _group.GetEntities()) {
var move = e.move;
var pos = e.position;
e.ReplacePosition(pos.x, pos.y + move.speed, pos.z);
}
}

ECS的优势

ECS快在哪,gameobject过于笨重,里面包含了一些渲染接口,物理计算接口等等,然而大部分时间只需要其中的某一项属性,比如位置。

多人游戏方面,可以很方便的写出简洁的代码,在游戏中当有人按下W,无论是网络消息还是本地输入,本地玩家按钮事件同步到其他玩家,其他玩家按钮事件同步到本地,对于ECS来说就是一个Entity。

后端验证,同理这就是一个消息包。

教学引导系统,任务系统方面。简单的说,每步教学操作,每个任务都是一个节点,每个节点对应一个状态,这个状态有对应的System。

日志输出,他还是一个消息包。

UI方面

因为可能使用UI来隔离逻辑,所以UI跳转这一部分可以交给UI自己解决,跟实际数据有关的交互用ECS。

顺序问题

比如定义了创建了一个Entity,添加RecordComponent,MoveComponent.

对应有RecordSystem,MoveSystem,经过常规的过滤后,是先执行RecordSystem还是MoveSystem呢?这取决于在Systems的注册顺序。

如果是下面的注册顺序,先执行Move,再执行Record。

1
2
3
new Systems()
.add(MoveSystem(contexts))
.add(RecordSystem(contexts));

环境配置中可能出现的问题

1、缺少Assembly-CSharp.csproj

如果MonoDev这个IDE开发,随便新建一个脚本打开后 ,IDE就会生成这么一个Assembly-CSharp.csproj工程配置文件

如果用VS开发,也会生成一个文件,只不过名字是和你的Unity工程名字一样,比如这里的ECS.csproj