仓库地址:ECS
一、标识组件
整个框架要解决的一个核心功能是筛选实体。根据ecs的理念,实体是组件的集合。那么要筛选特定组件组合的实体就需要知道实体身上有哪些组件,ecs框架不支持一个实体上面同时挂载两个同类组件,因此就只须给每类组件一个数值id进行标识。
利用Typescript的static关键字,给组件类声明个tid属性,每创建一类组件,给tid属性赋一个特定值。
export abstract class IComponent {
/**
* 组件的类型id,-1表示未给该组件分配id
*/
static tid: number = -1;
static compName: string;
/**
* 拥有该组件的实体
*/
ent!: ecs.Entity;
/**
* 组件被回收时会调用这个接口。可以在这里重置数据,或者解除引用。
*
* **不要偷懒,除非你能确定并保证组件在复用时,里面的数据是先赋值然后再使用。**
*/
abstract reset(): void;
}
在ecs上下文中有个compTip值,起始为0,每注册一个组件该值自增,从而保证每类组件有个唯一tid值。这里还需要对外提供一个register函数注册组件。
/**
* 组件缓存池
*/
let componentPools: Map<number, IComponent[]> = new Map();
/**
* 组件类型id
*/
let compTid = 0;
/**
* 组件构造函数
*/
let componentConstructors: ComponentConstructor[] = [];
/**
* 每个组件的添加和删除的动作都要派送到“关心”它们的group上。goup对当前拥有或者之前(删除前)拥有该组件的实体进行组件规则判断。判断该实体是否满足group
* 所期望的组件组合。
*/
let componentAddOrRemove: Map<number, ComponentAddOrRemove[]> = new Map();
export function register(componentName: string) {
return function (ctor: ComponentConstructor) {
if (ctor.tid === -1) {
ctor.tid = compTid++;
ctor.compName = componentName;
componentConstructors.push(ctor);
componentPools.set(ctor.tid, []);
componentAddOrRemove.set(ctor.tid, []);
}
else {
throw new Error(`重复注册组件: ${componentName}.`);
}
}
}
register函数,使用方式:
@ecs.register('Test')
class TestComponent extends ecs.IComponent {
reset() {}
}
二、实体添加组件
接下来是要给实体添加组件。因为组件的标识是数值id,为了更节省内存说明实体身上有哪些组件,可以用位来表示,某位为0表示没有该类组件,为1表示有该类组件。比如某个实体的tid为1,则实体添加该组件后,实体的mask属性值mask |= 1 << tid
。
因为javascrpit的特性,最多只能左移30位,即只能表示30个组件,这样可是不行的。因此ecs框架内部实现了个Mask类,能支持任意多种组件。
class Mask {
private mask: Uint32Array;
private size: number = 0;
constructor() {
let length = Math.ceil(compTid / 31);
this.mask = new Uint32Array(length);
this.size = length;
}
set(num: number) {
// https://stackoverflow.com/questions/34896909/is-it-correct-to-set-bit-31-in-javascript
// this.mask[((num / 32) >>> 0)] |= ((1 << (num % 32)) >>> 0);
this.mask[((num / 31) >>> 0)] |= (1 << (num % 31));
}
delete(num: number) {
this.mask[((num / 31) >>> 0)] &= ~(1 << (num % 31));
}
has(num: number) {
return !!(this.mask[((num / 31) >>> 0)] & (1 << (num % 31)));
}
or(other: Mask) {
for (let i = 0; i < this.size; i++) {
// &操作符最大也只能对2^30进行操作,如果对2^31&2^31会得到负数。当然可以(2^31&2^31) >>> 0,这样多了一步右移操作。
if (this.mask[i] & other.mask[i]) {
return true;
}
}
return false;
}
and(other: Mask) {
for (let i = 0; i < this.size; i++) {
if ((this.mask[i] & other.mask[i]) != this.mask[i]) {
return false;
}
}
return true;
}
clear() {
for (let i = 0; i < this.size; i++) {
this.mask[i] = 0;
}
}
}
三、实现筛选实体功能
这里再次引用Entitas的一张图
图里面的Groups表示实体的集合,集合里面的实体都是满足Group筛选规则的实体。这样就需要实现2个功能,一个是筛选功能,是一个存储某类实体的功能。
筛选功能主要是利用了Mask类。实体身上mask属性标识了实体拥有的组件,那么只要实体添加了组件或者删除了组件mask属性必然改变,判断mask中每一位是否满足要求即可筛选出符合要求的实体。
筛选基类BaseOf里面有个indices属性,里面存储组件的tid。从BaseOf派生出AnyOf、AllOf、ExcludeOf这三种筛选功能,分别表示“任意一个”、“”、“所有”和“不包含”的意思。
以AnyOf为例
abstract class BaseOf {
protected mask = new Mask();
public indices: number[] = [];
...省略部分代码
}
class AnyOf extends BaseOf {
public isMatch(entity: Entity): boolean {
return this.mask.or(entity.mask);
}
}
在isMatch方法中,利用or操作完成任意组件的逻辑判断。
为了能更加灵活的支持组件筛选功能,框架中实现了一个筛选器:Matcher。Matcher能实现多种规则的组合功能。比如:
ecs.allOf(Test1Comp, Test2Comp); // 表示同时拥有Test1Comp和Test2Comp组件的实体
ecs.allOf(Test1Comp).excludeOf(Test2Comp); // 表示拥有Test1Comp但是没有Test2Comp组件的实体
实现了筛选器,接下来就可以实现组件添加和删除的事件通知。
在ecs上下文中有个全局的componentAddOrRemove事件集合,key为组件id,value为关注该组件添加和移除的事件列表。创建Group的时候,会拿到Group身上的筛选器里面要筛选的组件tid,然后绑定该Group的观察组件添加和移除的回调方法。
export function createGroup<E extends Entity = Entity>(matcher: IMatcher): Group<E> {
let key = matcher.getKey();
let group = groups.get(key);
if (!group) {
group = new Group(matcher);
groups.set(key, group);
let careComponentTypeIds = matcher.indices;
for (let i = 0, len = careComponentTypeIds.length; i < len; i++) {
componentAddOrRemove.get(careComponentTypeIds[i])!.push(group.onComponentAddOrRemove.bind(group));
}
}
return group as unknown as Group<E>;
}
export class Group<E extends Entity = Entity> {
/**
* 实体筛选规则
*/
private matcher: IMatcher;
private _matchEntities: Map<number, E> = new Map();
private _entitiesCache: E[] | null = null;
/**
* 符合规则的实体
*/
public get matchEntities() {
if (this._entitiesCache === null) {
this._entitiesCache = Array.from(this._matchEntities.values());
}
return this._entitiesCache;
}
/**
* 当前group中实体的数量。
*
* 不要手动修改这个属性值。
*/
public count = 0; // 其实可以通过this._matchEntities.size获得实体数量,但是需要封装get方法。为了减少一次方法的调用所以才直接创建一个count属性
/**
* 获取matchEntities中第一个实体
*/
get entity(): E {
return this.matchEntities[0];
}
private _enteredEntities: Map<number, E> | null = null;
private _removedEntities: Map<number, E> | null = null;
constructor(matcher: IMatcher) {
this.matcher = matcher;
}
public onComponentAddOrRemove(entity: E) {
if (this.matcher.isMatch(entity)) { // Group只关心指定组件在实体身上的添加和删除动作。
this._matchEntities.set(entity.eid, entity);
this._entitiesCache = null;
this.count++;
if(this._enteredEntities) {
this._enteredEntities.set(entity.eid, entity);
this._removedEntities!.delete(entity.eid);
}
}
else if (this._matchEntities.has(entity.eid)) { // 如果Group中有这个实体,但是这个实体已经不满足匹配规则,则从Group中移除该实体
this._matchEntities.delete(entity.eid);
this._entitiesCache = null;
this.count--;
if(this._enteredEntities) {
this._enteredEntities.delete(entity.eid);
this._removedEntities!.set(entity.eid, entity);
}
}
}
public watchEntityEnterAndRemove(enteredEntities: Map<number, E>, removedEntities: Map<number, E>) {
this._enteredEntities = enteredEntities;
this._removedEntities = removedEntities;
}
clear() {
this._matchEntities.clear();
this._entitiesCache = null;
this.count = 0;
this._enteredEntities?.clear();
this._removedEntities?.clear();
}
}
实体身上虽然可能有很多种组件,但是每个Group只关注特定的几个组件,也就是说,只有实体添加或删除Group关注的组件,才会触发Group的筛选功能。
实体添加删除组件会调用全局广播事件函数
function broadcastComponentAddOrRemove(entity: Entity, componentTypeId: number) {
let events = componentAddOrRemove.get(componentTypeId);
for (let i = events!.length - 1; i >= 0; i--) {
events![i](entity);
}
}
至此完成了实体的筛选功能。
四、系统(System)
系统是实现逻辑的地方,用户自定的系统必须继承自ecs.ComblockSystem。每个系统会有个Group类型的group属性,表示改系统关心的实体集合。
默认继承ecs.ComblockSystem需要实现两个方法:
abstract filter(): IMatcher;
abstract update(entities: E[]): void;
filter方法需要返回筛选规则,即通过ecs.allOf、ecs.anyOf、ecs.onlyOf等方法创建出来的筛选器。update为每帧会执行的方法,前提是该系统关联的group有实体。
框架还提供了IEntityEnterSystem、IEntityRemoveSystem、ISystemFirstUpdate接口扩展系统功能。
export interface IEntityEnterSystem<E extends Entity = Entity> {
entityEnter(entities: E[]): void;
}
export interface IEntityRemoveSystem<E extends Entity = Entity> {
entityRemove(entities: E[]): void;
}
export interface ISystemFirstUpdate<E extends Entity = Entity> {
firstUpdate(entities: E[]): void;
}
用户实现IEntityEnterSystem接口,那么当有新实体进入系统时会先执行entityEnter方法;实现IEntityRemoveSystem接口,当实体不满足规则从group中移除后会执行entityRemove方法;实现ISystemFirstUpdate接口,当系统第一次执行时会先执行firstUpdate方法,后面不会再执行firstUpdate方法。
注意:entityEnter、firstUpdate、update、entityRemove方法都不是立即执行的,只会在轮到该系统执行时才会执行到这些方法。
五、性能
框架里面提供了组件缓存和实体缓存池。删除实体的时候会将实体身上的所有组件都放入组件缓存池中,同时也会将实体放入实体缓存池中。
/**
* 组件缓存池
*/
let componentPools: Map<number, IComponent[]> = new Map();
/**
* 实体对象缓存池
*/
let entityPool: Entity[] = [];
function destroyEntity(entity: Entity) {
if (eid2Entity.has(entity.eid)) {
entityPool.push(entity);
eid2Entity.delete(entity.eid);
}
else {
console.warn('试图销毁不存在的实体!');
}
}
创建组件时优先充组件缓存池中拿组件对象
function createComponent<T extends IComponent>(ctor: ComponentConstructor<T>): T {
let component = componentPools.get(ctor.tid)!.pop() || new componentConstructors[ctor.tid];
return component as T;
}
创建实体的时候也优先从实体缓存池中拿实体对象
export function createEntity<E extends Entity = Entity>(): E {
let entity = entityPool.pop() || new Entity();
// @ts-ignore
entity.eid = eid++;
eid2Entity.set(entity.eid, entity);
return entity as E;
}
框架还对group进行了缓存,因为整个游戏世界中同一类集合应该只有一个。这样,每个组件被添加或者删除时也就不会有多余的筛选操作。
let groups: Map<string, Group> = new Map();
export function createGroup<E extends Entity = Entity>(matcher: IMatcher): Group<E> {
let key = matcher.getKey();
let group = groups.get(key);
...
return group as unknown as Group<E>;
}
有了group缓存机制,ecs框架也能更好的支持实时查找实体的功能
export function query<E extends Entity = Entity>(matcher: IMatcher): E[] {
let group = groups.get(matcher.getKey());
if(!group) {
group = createGroup(matcher);
eid2Entity.forEach(group.onComponentAddOrRemove, group);
}
return group.matchEntities as E[];
}