实体组件系统要求特定的设计。
ECS 遵循组合优于继承的原则
处理组件池(本质上是原始数据)时,参考实际组件类型来处理这些数据是有意义的——因为您需要为每个组件应用特定的行为。
装饰器模式很好地配合组合,通过包装类型添加行为。它还允许EntityManager
将职责委派给组件池,而不是拥有一个必须处理所有情况的庞大决策树。
让我们实现一个例子。
假设有一个绘图功能。这将是一个“系统”,它遍历所有具有物理和可见组件的实体,并绘制它们。可见组件通常可以包含有关实体外观的一些信息(例如人类、怪物、四处飞舞的火花、飞箭),并使用物理组件来知道在哪里绘制它。
- 实体.cs
一个实体通常由一个 ID 和一个附加到它的组件列表组成。
class Entity
{
public Entity(Guid entityId)
{
EntityId = entityId;
Components = new List<IComponent>();
}
public Guid EntityId { get; }
public List<IComponent> Components { get; }
}
- 组件.cs
从标记界面开始。
interface IComponent { }
enum Appearance : byte
{
Human,
Monster,
SparksFlyingAround,
FlyingArrow
}
class VisibleComponent : IComponent
{
public Appearance Appearance { get; set; }
}
class PhysicalComponent : IComponent
{
public double X { get; set; }
public double Y { get; set; }
}
- 系统.cs
为SystemEntities
.
interface ISystem
{
ISet<Guid> SystemEntities { get; }
Type[] ComponentTypes { get; }
void Run();
}
class DrawingSystem : ISystem
{
public DrawingSystem(params Type[] componentTypes)
{
ComponentTypes = componentTypes;
SystemEntities = new HashSet<Guid>();
}
public ISet<Guid> SystemEntities { get; }
public Type[] ComponentTypes { get; }
public void Run()
{
foreach (var entity in SystemEntities)
{
Draw(entity);
}
}
private void Draw(Guid entity) { /*Do Magic*/ }
}
- 组件池.cs
接下来,我们将为即将发生的事情奠定基础。我们的组件池还应该有一个非通用接口,当我们无法提供组件类型时,我们可以依靠它。
interface IComponentPool
{
void RemoveEntity(Guid entityId);
bool ContainsEntity(Guid entityId);
}
interface IComponentPool<T> : IComponentPool
{
void AddEntity(Guid entityId, T component);
}
class ComponentPool<T> : IComponentPool<T>
{
private Dictionary<Guid, T> component = new Dictionary<Guid, T>();
public void AddEntity(Guid entityId, T component)
{
this.component.Add(entityId, component);
}
public void RemoveEntity(Guid entityId)
{
component.Remove(entityId);
}
public bool ContainsEntity(Guid entityId)
{
return component.ContainsKey(entityId);
}
}
下一步是泳池装饰。装饰器模式是通过暴露与其包装的类相同的接口来实现的,并在流程中应用任何所需的行为。在我们的例子中,我们想要检查添加的实体是否拥有系统所需的所有组件类型。如果他们这样做,请将它们添加到集合中。
class PoolDecorator<T> : IComponentPool<T>
{
private readonly IComponentPool<T> wrappedPool;
private readonly EntityManager entityManager;
private readonly ISystem system;
public PoolDecorator(IComponentPool<T> componentPool, EntityManager entityManager, ISystem system)
{
this.wrappedPool = componentPool;
this.entityManager = entityManager;
this.system = system;
}
public void AddEntity(Guid entityId, T component)
{
wrappedPool.AddEntity(entityId, component);
if (system.ComponentTypes
.Select(t => entityManager.GetComponentPool(t))
.All(p => p.ContainsEntity(entityId)))
{
system.SystemEntities.Add(entityId);
}
}
public void RemoveEntity(Guid entityId)
{
wrappedPool.RemoveEntity(entityId);
system.SystemEntities.Remove(entityId);
}
public bool ContainsEntity(Guid entityId)
{
return wrappedPool.ContainsEntity(entityId);
}
}
如前所述,您可以将检查和管理系统集合的负担放在EntityManager
. 但从长远来看,我们当前的设计倾向于降低复杂性并提供更大的灵活性。只需为它所属的每个系统包装一次池。如果系统需要非默认行为,那么您可以为该系统创建一个专门的新装饰器——而不会干扰其他系统。
- 实体管理器.cs
协调器(又名调解器、控制器……)
class EntityManager
{
List<ISystem> systems;
Dictionary<Type, object> componentPools;
public EntityManager()
{
systems = new List<ISystem>();
componentPools = new Dictionary<Type, object>();
ActiveEntities = new HashSet<Guid>();
}
public ISet<Guid> ActiveEntities { get; }
public Guid CreateEntity()
{
Guid entityId;
do entityId = Guid.NewGuid();
while (!ActiveEntities.Add(entityId));
return entityId;
}
public void DestroyEntity(Guid entityId)
{
componentPools.Values.Select(kp => (IComponentPool)kp).ToList().ForEach(c => c.RemoveEntity(entityId));
systems.ForEach(c => c.SystemEntities.Remove(entityId));
ActiveEntities.Remove(entityId);
}
public void AddSystems(params ISystem[] system)
{
systems.AddRange(systems);
}
public IComponentPool GetComponentPool(Type componentType)
{
return (IComponentPool)componentPools[componentType];
}
public IComponentPool<TComponent> GetComponentPool<TComponent>() where TComponent : IComponent
{
return (IComponentPool<TComponent>)componentPools[typeof(TComponent)];
}
public void AddComponentPool<TComponent>(IComponentPool<TComponent> componentPool) where TComponent : IComponent
{
componentPools.Add(typeof(TComponent), componentPool);
}
public void AddComponentToEntity<TComponent>(Guid entityId, TComponent component) where TComponent : IComponent
{
var pool = GetComponentPool<TComponent>();
pool.AddEntity(entityId, component);
}
public void RemoveComponentFromEntity<TComponent>(Guid entityId) where TComponent : IComponent
{
var pool = GetComponentPool<TComponent>();
pool.RemoveEntity(entityId);
}
}
- 程序.cs
这一切都聚集在一起。
class Program
{
static void Main(string[] args)
{
#region Composition Root
var entityManager = new EntityManager();
var drawingComponentTypes =
new Type[] {
typeof(VisibleComponent),
typeof(PhysicalComponent) };
var drawingSystem = new DrawingSystem(drawingComponentTypes);
var visibleComponent =
new PoolDecorator<VisibleComponent>(
new ComponentPool<VisibleComponent>(), entityManager, drawingSystem);
var physicalComponent =
new PoolDecorator<PhysicalComponent>(
new ComponentPool<PhysicalComponent>(), entityManager, drawingSystem);
entityManager.AddSystems(drawingSystem);
entityManager.AddComponentPool(visibleComponent);
entityManager.AddComponentPool(physicalComponent);
#endregion
var entity = new Entity(entityManager.CreateEntity());
entityManager.AddComponentToEntity(
entity.EntityId,
new PhysicalComponent() { X = 0, Y = 0 });
Console.WriteLine($"Added physical component, number of drawn entities: {drawingSystem.SystemEntities.Count}.");
entityManager.AddComponentToEntity(
entity.EntityId,
new VisibleComponent() { Appearance = Appearance.Monster });
Console.WriteLine($"Added visible component, number of drawn entities: {drawingSystem.SystemEntities.Count}.");
entityManager.RemoveComponentFromEntity<VisibleComponent>(entity.EntityId);
Console.WriteLine($"Removed visible component, number of drawn entities: {drawingSystem.SystemEntities.Count}.");
Console.ReadLine();
}
}
也许可以创建不需要的集合entityId
,因此它们只存储对应该更新的组件的引用。
正如引用的 wiki 中所述,这实际上是不鼓励的。
为每个实体使用唯一 ID 是一种常见的做法。这不是必需的,但它有几个优点:
- 可以使用 ID 而不是指针来引用实体。这更健壮,因为它允许在不留下悬空指针的情况下销毁实体。
- 它有助于在外部保存状态。当再次加载状态时,不需要重建指针。
- 数据可以根据需要在内存中随机播放。
- 通过网络进行通信时,可以使用实体 ID 来唯一标识实体。