ECS系列二:框架解析

仓库地址: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的一张图
image

图里面的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[];
}
1赞

顶!d=====( ̄▽ ̄*)b

马克加索尔