关于ECS框架

在ECS中每个基本单位都是一个实体,一个实体由N个组件组成。然后拥有相同组件的实体会被特定的系统处理特定的逻辑。
E(ntity)——C(omponent)——S(ystem)

  • Entity 实体,组件的载体本身并无意义,拥有什么功能完全取决于拥有什么组件。但是可以通过在游戏中增加或删除组件来改变实体的行为。

  • Component 组件,包含了代表其对应特性的数据,所以组件中没有任何方法。

  • System 系统,用来处理一个或多个具有相同组件的实体,即系统中没有任何数据。

    Jenny设置面板

    1.png

  • Project Path C#项目工程文件路径。

  • Target Directory 自动生成代码的路径。

  • Assemblies 引用程序集的路径,这里的默认值也是Unity自动引用的路径,除非自己修改过。

  • Contexts 需要生成的上下文,后面会介绍上下文。

  • Ignore Namespaces 生成的代码文件名称是否忽略命名空间。


Entity实体

实体作为ECS的三大基本概念之一,实体作为组件的载体,本身并无实际意义,最核心的数据便是唯一ID,和Untiy的GameObject类似。在游戏世界中可以被创建,被销毁,可以添加组件,删除组件。

实体初始化

Entitas中的实体只能由Context的CreateEntity()方法创建。可以大概看下源码:

1
2
3
4
5
6
7
8
9
10
11
12
    //判断对象池是否有空闲对象
if (_reusableEntities.Count > 0) {
entity = _reusableEntities.Pop();
//如果有,则拿出来,同时赋予新的ID
entity.Reactivate(_creationIndex++);
} else {
//如果没有,则创建,并且初始化
entity = _entityFactory();
entity.Initialize(_creationIndex++, _totalComponents, _componentPools,_contextInfo, _aercFactory(entity));
}


实体的销毁

实体销毁是调用Destroy()方法,但是这个方法并不是真正执行销毁逻辑,Context内部提供了用来管理Entity的对象池,调用Destroy()方法时只是将实体身上的组件,委托事件等移除然后放入对象池等待下次使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void Destroy() {
if (!_isEnabled) {
throw new EntityIsNotEnabledException("Cannot destroy " + this + "!");
}
if (OnDestroyEntity != null) {
OnDestroyEntity(this);
}
}

public void InternalDestroy() {
_isEnabled = false;
RemoveAllComponents();
OnComponentAdded = null;
OnComponentReplaced = null;
OnComponentRemoved = null;
OnDestroyEntity = null;
}

Context 上下文

Context是Entity的上下文环境,用来管理当前环境下的所有Entity和Group的创建和回收。可以同时存在多个Context,Entitas默认会生成两个Context,分别是Game,Input。可以在Jenny的设置面板修改Context。
注意:设置面板中的第一个Context将会被作为默认的,会对之后生成组件产生影响,之后详细讲解。

生成的Context代码存放在Generated文件夹下。在生成Context时同时会生成Lookup类和一个Matcher类。

  • Lookup 用来管理当前Context的所有组件组件信息。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public static class InputComponentsLookup 
    {
    public const int TotalComponents = 0;

    public static readonly string[] componentNames = {

    };

    public static readonly System.Type[] componentTypes = {

    };
    }
  • Matcher 用来定义当前Context中的实体的筛选条件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public sealed partial class InputMatcher {

    public static Entitas.IAllOfMatcher<InputEntity> AllOf(params int[] indices) {
    return Entitas.Matcher<InputEntity>.AllOf(indices);
    }

    public static Entitas.IAllOfMatcher<InputEntity> AllOf(params Entitas.IMatcher<InputEntity>[] matchers) {
    return Entitas.Matcher<InputEntity>.AllOf(matchers);
    }

    public static Entitas.IAnyOfMatcher<InputEntity> AnyOf(params int[] indices) {
    return Entitas.Matcher<InputEntity>.AnyOf(indices);
    }

    public static Entitas.IAnyOfMatcher<InputEntity> AnyOf(params Entitas.IMatcher<InputEntity>[] matchers) {
    return Entitas.Matcher<InputEntity>.AnyOf(matchers);
    }
    }
    同时存在的多个Context每个之间互不影响。所有的Context由Contexts管理。

    Contexts

    Contexts是个单例。通过Contexts.sharedInstance访问,内部持有所有Context的引用。是由Entitas代码生成器生成,无需手动实现也不能够手动实现。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public partial class Contexts : Entitas.IContexts {
    public static Contexts sharedInstance {
    get {
    if (_sharedInstance == null) {
    _sharedInstance = new Contexts();
    }
    return _sharedInstance;
    }
    set { _sharedInstance = value; }
    }

    static Contexts _sharedInstance;

    public GameContext game { get; set; }

    public InputContext input { get; set; }

    public Entitas.IContext[] allContexts { get { return new Entitas.IContext [] { game, input }; } }

    public Contexts() {
    game = new GameContext();
    input = new InputContext();
    }
    }

Component组件

组件是ECS框架中基础的数据结构单元。
每个Compoent只有数据,不包含任何处理数据的方法。
在内存中相同类型的组件是紧密排列的,这样在System中遍历拥有相同组件的实体时大大的提高内存命中率。
这也是ECS框架用来做很多大型项目或者多物体的项目的原因之一。
Entitas中所有组件全部继承自IComponent接口。同时Entitas提供了各种特殊标签属性,在生成代码的时候为我们提供特殊功能。
例如 [Context] 指定将该组件添加一个或者多个Context中。
我们定义的组件在生成的代码的时候会自动生成所属的Context文件夹下的Components文件夹中。
一个Context可以包含多个组件,自动生成时实际上个是通过partial(不完全类)的方式为当前Context下的Entity提供相关接口。如果一个组件被添加到多个Context中,则每个Context下都会有该组件。

前面说过组件内部只有数据,但是我们创建组件的时候也可以不包含数据。如果包含任意类型的数据,生成代码时会自动帮我们定义 has,Add,Replace,Remove四个接口,如果没有任何数据则只会给我们提供一个bool的属性。
例 :
我们定义如下两个接口:

1
2
3
4
5
6
7
8
9
10
11
using Entitas;
//组件一 不包含数据
public class TestFlagComponent : IComponent
{

}
//组件二 包含数据
public class TestValueComponent : IComponent
{
public int value;
}

组件一自动生成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public partial class GameEntity {

static readonly TestFlagComponent testFlagComponent = new TestFlagComponent();

public bool isTestFlag {
get { return HasComponent(GameComponentsLookup.TestFlag); }
set {
if (value != isTestFlag) {
var index = GameComponentsLookup.TestFlag;
if (value) {
var componentPool = GetComponentPool(index);
var component = componentPool.Count > 0
? componentPool.Pop()
: testFlagComponent;

AddComponent(index, component);
} else {
RemoveComponent(index);
}
}
}
}
}

组件二自动生成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public partial class GameEntity {

public TestValueComponent testValue { get { return (TestValueComponent)GetComponent(GameComponentsLookup.TestValue); } }
public bool hasTestValue { get { return HasComponent(GameComponentsLookup.TestValue); } }

public void AddTestValue(int newValue) {
var index = GameComponentsLookup.TestValue;
var component = (TestValueComponent)CreateComponent(index, typeof(TestValueComponent));
component.value = newValue;
AddComponent(index, component);
}

public void ReplaceTestValue(int newValue) {
var index = GameComponentsLookup.TestValue;
var component = (TestValueComponent)CreateComponent(index, typeof(TestValueComponent));
component.value = newValue;
ReplaceComponent(index, component);
}

public void RemoveTestValue() {
RemoveComponent(GameComponentsLookup.TestValue);
}
}

添加组件

一个Entity在被调用CreateEntity()方法创建出来时没有任何组件信息,我们需要在合适的时机增加,删除或者修改组件来改变Entity的行为。

  • 对于没有数据的组件我们调用bool型的属性来添加或者移除该组件

    true 添加组件
    false 移除移除

  • 对于有数据的组组件

    Add 添加组件
    Replace 替换组件
    Remove 移除移除


System

System是一个单纯的逻辑处理类,在特定的时间执行系统内部的逻辑,这些逻辑中可以改变Entity上Component的数据和状态, 原则上来说应该是只有逻辑没有数据。
Entitas给我们提供了五种系统类型:

  • IInitializeSystem
  • IExecuteSystem
  • ICleanupSystem
  • ITearDownSystem
  • ReactiveSystem
    一般来说一个System只会用来处理一种逻辑,每个System之间相互也不需要相互调用(独立减少耦合)。完全是通过框架外部或者Entity上Component的数据来驱动。

    Systems

    一个System一般只用来处理一种逻辑,游戏中会有很多个System。那么就需要用到Systems来管理这些System。

Systems内部维护了4个不同的List来保存不同类型的System。

1
2
3
4
5
6
7
8
public Systems() {
//初始化各个List
_initializeSystems = new List<IInitializeSystem>();
_executeSystems = new List<IExecuteSystem>();
_cleanupSystems = new List<ICleanupSystem>();
_tearDownSystems = new List<ITearDownSystem>();
}

我们需要通过Add(ISystem system)将System添加到Systems中。但是不用管System会被添加到那个List中,Entitas会自动帮我们处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//将不同的System添加到对应的列表中
public virtual Systems Add(ISystem system) {
var initializeSystem = system as IInitializeSystem;
if (initializeSystem != null) {
_initializeSystems.Add(initializeSystem);
}

var executeSystem = system as IExecuteSystem;
if (executeSystem != null) {
_executeSystems.Add(executeSystem);
}

var cleanupSystem = system as ICleanupSystem;
if (cleanupSystem != null) {
_cleanupSystems.Add(cleanupSystem);
}

var tearDownSystem = system as ITearDownSystem;
if (tearDownSystem != null) {
_tearDownSystems.Add(tearDownSystem);
}

return this;
}

Systems继承了IInitializeSystem, IExecuteSystem, ICleanupSystem, ITearDownSystem,并在内部实现了Cleanup(),Execute(),Initialize(),TearDown()等接口用来执行4个List中的System。
所以我们需要只要再外部调用Systems的这4个接口即可调用了内部的所有System。

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
//驱动初始化系统执行Initialize()方法
public virtual void Initialize() {
for (int i = 0; i < _initializeSystems.Count; i++) {
_initializeSystems[i].Initialize();
}
}

//驱动每帧执行的系统执行Execute()方法
public virtual void Execute() {
for (int i = 0; i < _executeSystems.Count; i++) {
_executeSystems[i].Execute();
}
}

//驱动清理系统执行Cleanup()方法
public virtual void Cleanup() {
for (int i = 0; i < _cleanupSystems.Count; i++) {
_cleanupSystems[i].Cleanup();
}
}

//驱动结束系统执行TearDown()方法
public virtual void TearDown() {
for (int i = 0; i < _tearDownSystems.Count; i++) {
_tearDownSystems[i].TearDown();
}
}

Feature

在实际开发过程中我们可能需要知道当前正在运行的有哪些System等调试信息,Entitas会为我们自动生成Feature这个类来帮我们调试。
Feature主要用于在编辑器模式下开启visual debugging时收集各个系统的数据,同时在Unity中展示。
所以在开启visual debugging时Feature继承自DebugSystems,而DebugSystems又是继承自Systems,并在内部做一些数据收集的工作与展示的工作。
当关闭visual debugging时Feature会直接继承自Systems。
visual debugging的开关在 菜单栏Tools->Entitas->Preferences


Matcher Collector

2.png

Group

一个Context中可能会同时存在很多个Entity,但是有些时候我们只需要处理某些Entity,那么我们可以通过Group来快速访问,
每个Context内部维护一个Group对象集合,调用GetGroup()方法可以拿到Group,相同的Matcher参数拿到的是相同得Group对象。
Group是实时更新的一个具有相同筛选条件的Entity的组合,它会自动添加具有筛选条件的Entity,并删除失去筛选条件的Entity,以提高下一次的访问速度。

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
   public IGroup<TEntity> GetGroup(IMatcher<TEntity> matcher)
{
if (!_groups.TryGetValue(matcher, out IGroup<TEntity> group))
{
group = new Group<TEntity>(matcher);
TEntity[] entities = GetEntities();
for (int i = 0; i < entities.Length; i++)
{
group.HandleEntitySilently(entities[i]);
}
_groups.Add(matcher, group);
for (int j = 0; j < matcher.indices.Length; j++)
{
int num = matcher.indices[j];
if (_groupsForIndex[num] == null)
{
_groupsForIndex[num] = new List<IGroup<TEntity>>();
}
_groupsForIndex[num].Add(group);
}
if (this.OnGroupCreated != null)
{
this.OnGroupCreated(this, group);
}
}
return group;
}

可以通过GetEntities()拿到Group中得所有Entity。通过ContainsEntity()接口判断Group中是否包含某个Entity。
Gorup提供了OnEntityAdded,OnEntityRemoved,OnEntityUpdated事件委托监听Group内部得Entity变化。

1
2
3
gameContext.GetGroup(GameMatcher.Position).OnEntityAdded += (group, entity, index, component) => {
// Do something
};

Matcher

Matcher是Group的筛选条件,筛选条件就是Context中所拥有的Component。
例:
我们有MoveComponent和AttackComponent两个组件存在Game Context中。则我们可以有以下几种查询方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
//查找所有有MoveComponent的Entity
var moveEntitys = contexts.game.GetGroup(GameMatcher.Move);

//查找所有有AttackComponent的Entity
var attackEntitys = contexts.game.GetGroup(GameMatcher.Attack);

//查找所有有MoveComponent和AttackComponent的Entity
int[] allMatcher = { GameComponentsLookup.Move,GameComponentsLookup.Attack };
var AllEntitys = contexts.game.GetGroup(GameMatcher.AllOf(allMatcher));

//查找所有有MoveComponent或AttackComponent的Entity
int[] anyMatcher = { GameComponentsLookup.Move, GameComponentsLookup.Attack };
var AnyEntitys = contexts.game.GetGroup(GameMatcher.AnyOf(anyMatcher));

Collector

通过Group和Matcher已经可以拿到所有符合条件的Entity,例如所有具有MoveComponent的Entity。
也说过了Group会实时更新,但是如果我们需要知道Group中当前哪些Entity是新加进来的。则可以通过Collector来找到。
例:

1
2
3
4
//查找所有有MoveComponent的Entity
var moveEntitys = contexts.game.GetGroup(GameMatcher.Move);
//找到哪些Entity是刚刚获得了MoveComponent然后被添加到Gorup中的
var collect = moveEntitys.CreateCollector<GameEntity>(GroupEvent.Added);s

GroupEvent

1
2
3
+ Added         添加到Gorup中的
+ Removed 从Group中移除的
+ AddedOrRemoved 所有变化的(包含添加的和移除的)