[ ECS + CocosCreator ] ECS游戏框架和CocosCreator结合的讨论

ECS结合CocosCreator讨论

  • Creator 版本: 2.4.3

  • 讨论一下关于ECS框架和Creator结合的问题。

  • 个人想要尝试ECS框架的原因

    • 通过脚本继承的方式会增加团队合作的成本。
    • 维护父类的脚本会违背编程的四大原则,且工作量较大。
    • 策划更改需求的时候就会更改之前的脚本或者继承重写方法,导致继承混乱。
    • 维护好脚本后,通过排列组合的方式可以组合出很多不一样的功能。灵活度和复用性比较高。
    • 方便之后接手项目的人阅读和维护。
    • 以上都是个人遇到的问题(当然很大部分是因为我菜,嘿嘿嘿)
  • 个人发帖的观点(个人哦)

    • 技术在于讨论和分享,互联网的意义也在于此。不怕错,敢于提问题和分享技术才是互联网的良性发展。
  • 参考文章:

什么是ECS框架?

E : 表示的是 Entity 也就是实例(负责关联组件)。
C : 表示的是 Component 也就是组件,和实例的关系是一个实例对应多个组件,只负责数据的记录,不进行任何逻辑运算,这很重要。
S : 表示的是 System 也就是系统, 负责整个游戏的运作,数据的运算。类似于,我只要关心我要修改的组件,改了就完事儿了,其他我不管。
以上是个人看法,如果有错误的地方,希望大佬指出。

个人理解的ECS框架的用法

  • 如果我们要设计一个飞行射击的游戏,那么我们可能会需要以下几个组件
    • 血量,移动,AI,攻击, 敌人,玩家,系统输入
  • 那么我们就可以这样进行组合
    • 玩家(血量,移动,攻击,玩家,系统输入)
    • 敌人(血量,移动,AI,敌人)
    • BOSS(血量,移动,AI,敌人,其他特殊组件)
  • 我们可能需要以下几个系统
    • 玩家控制系统,普通敌人AI控制系统,BOSS敌人AI控制系统。
  • 实际使用
    通过这种组件组合的形式,我们就能够实现很多的角色功能。我们维护好多个组件后,之后的开发任务重心就可以放到系统脚本的维护和编写上来,策划增加新的功能,我们就增加新的组件和系统脚本,之后动态修改绑定的组件。这样,就不需要我们再次编写和修改之前的脚本。举个例子,如果策划这个时候和我们说,需要敌人会飞。那我们只需要增加一个飞行的组件,再增加一个飞行的系统控制。然后这么做:
    • 敌人(血量,移动,AI,敌人,飞行)
    • BOSS(血量,移动,AI,敌人,其他特殊组件,飞行)
      这样的话,我们的敌人是不是就会飞了,哈哈哈。如果有一个敌人是不会飞并且不会移动的,那么我们只要这样组合:
    • 敌人(血量,AI,敌人)
      没错,把移动和飞行的脚本去掉就可以了(理论上来说,我们去掉移动脚本就可以了 滑稽脸)。

以上是个人观点,如果说的有问题,望大家矫正。

一个简单的ECS框架结合Creator的例子(抛砖引玉)

  • ECSContextManager(ECSWorld,不一定是要单例,我做的是单例)
    • ECSEntityMananger : 控制实例,和组件的增删减。
    • ECSSystemManager : 控制系统的运行,和系统的结束开启。
  • ECSComponent(组件)
    • ECSInputComponent : 接收玩家的输入。
_mark: number = ECS_COMP_ENUM.ECS_INPUT;

value: number = 0; // 用来记录按键
- ECSVelocityComponent : 玩家的速度和方向。
export default class ECSVelocityComponent extends ECSComponent {
 _mark: number = ECS_COMP_ENUM.ECS_VELOCITY;
    private _value: cc.Vec2 = cc.v2(0, 0);
    private _speed: number = 5;
    private _maxSpeed: number = 10;
    private _minSpeed: number = 0;

    public get maxSpeed(): number {
        return this._maxSpeed;
    }
    public set maxSpeed(value: number) {
        this._maxSpeed = value;
    }
    public get minSpeed(): number {
        return this._minSpeed;
    }
    public set minSpeed(value: number) {
        this._minSpeed = value;
    }
    public get value(): cc.Vec2 {
        return this._value;
    }
    public set value(value: cc.Vec2) {
        this._value = value;
    }
    public get speed(): number {
        return this._speed;
    }
    public set speed(value: number) {
        this._speed = Math.max(Math.min(value, this.maxSpeed), this.minSpeed);
    }

    constructor(speed?: number, maxSpeed?: number, minSpeed?: number, direction?: cc.Vec2) {
        super();
        this.value = direction || this.value;
        this.speed = speed || this.speed;
        this.maxSpeed = maxSpeed || this.maxSpeed;
        this.minSpeed = minSpeed || this.minSpeed;
    }
- ECSPositionComponent : 角色的位置。
_mark: number = ECS_COMP_ENUM.ECS_POSITION;
    public value: cc.Vec2 = cc.v2(0, 0);
     - ECSNodeComponent : 实例是否挂载节点。(这个脚本我还在优化)
_mark: number = ECS_COMP_ENUM.ECS_NODE; // 节点标志
    private _pool: cc.NodePool; // 节点对象池容器
    private _tiledLayer: cc.TiledLayer; // 节点地图容器
    private _node: cc.Node; // 包含的节点

    public get pool(): cc.NodePool {
        return this._pool;
    }
    public set pool(value: cc.NodePool) {
        this._pool = value;
    }
    public get tiledLayer(): cc.TiledLayer {
        return this._tiledLayer;
    }
    public set tiledLayer(value: cc.TiledLayer) {
        this._tiledLayer = value;
    }
    public get node(): cc.Node {
        return this._node;
    }
    public set node(value: cc.Node) {
        this._node = value;
    }

    constructor(node: cc.Node, pool?: cc.NodePool, tiledLayer?: cc.TiledLayer) {
        super();
        this.node = node;
        this.pool = pool;
        this.tiledLayer = tiledLayer;
    }

    destroy(): void {

        if (this.tiledLayer != undefined && this.tiledLayer != null) {
            this.tiledLayer.removeUserNode(this.node);
        }

        if (this.pool != null && this.pool != undefined) {
            this.node.removeFromParent();
            this.pool.put(this.node);
            return;
        }
        this.pool = null;
        this.tiledLayer = null;
        this.node.removeFromParent();
        this.node.destroy();
        this.node = null;

    }
- ECSHealthComponent : 玩家是否有血量组件。(暂时没有用)
 - 暂时不举例子
  • 主游戏脚本(挂载在主场景上的脚本)
    MainComp
 @property({
        type: cc.Prefab,
        displayName: '角色预制体'
    })
    role: cc.Prefab = null;
    heroId: string = 'hero_01';

    private _systemManager: ECSSystemManager = null;

    public get systemManager(): ECSSystemManager {
        return this._systemManager;
    }
    public set systemManager(value: ECSSystemManager) {
        this._systemManager = value;
    }

    async start() {

        cc.view.setOrientation(cc.macro.ORIENTATION_LANDSCAPE); // 屏幕设置为横屏
        GameController.InitGameController(() => { }, async () => {

            let contextManager: ECSContextManager = ECSContextManager.getInstance(); // 获取世界实例
            this._systemManager = this.CreateSystem(contextManager); // 创建系统管理

            // cc.log(this._systemManager.systemList[0].__proto__.constructor.name); // 获得函数方法名
            let playerStr = 'prefab/role';
            let res = await NodeController.getInstance().createNode(playerStr);

            let player = contextManager.game.createEntity(new ECSPlayerEntity());
            let hero = RoleController.getInstance().getHeroById(this.heroId);
            this.node.addChild(res);

             // 这里给实例绑定我们想要绑定的脚本(比如一个玩家可以控制的实例需要 速度组件 位置组件 节点组件 和操作输入组件 以及血量组件)
            contextManager.game.addComponent(new ECSInputComponent(), ECS_COMP_ENUM.ECS_INPUT, player)
                .addComponent(new ECSVelocityComponent(hero.moveSpeed, hero.maxMoveSpeed, hero.minMoveSpeed),  ECS_COMP_ENUM.ECS_VELOCITY, player)
                .addComponent(new ECSPositionComponent(), ECS_COMP_ENUM.ECS_POSITION, player)
                .addComponent(new ECSNodeComponent(res), ECS_COMP_ENUM.ECS_NODE, player)
                .addComponent(new ECSHealthComponent(hero), ECS_COMP_ENUM.ECS_HEALTH, player);

	    // 没什么的,可以忽视,这是我对状态机的尝试
            let test = new ECSStateComponent();
            StateMachine.apply(test, { // 这是我对状态机的尝试
                init: 'A',
                transitions: [
                    { name: 'step', from: 'A', to: 'B' },
                    { name: 'step', from: 'B', to: 'B' },
                ]
            });

        }, this);

    }

    testCallFunc(): void {
        cc.log('test callFunc.');
    }

    update(dt: number): void {  // 这个很关键嗷
        if (this.systemManager != undefined && this.systemManager != null) {
            this._systemManager.Execute(dt);
            this._systemManager.CleanUp();
        }

    }

    onDestroy(): void {
        this._systemManager.TearDown();
    }

    private CreateSystem(contextManager: ECSContextManager): ECSSystemManager {
        let sysMgr = new ECSSystemManager();
        sysMgr.addSystem(new ECSChangePlayerVelocitySystem()) // 添加角色移动位置系统
            .addSystem(new ECSChangePositionSystem(contextManager)) // 添加改变位置系统
            .addSystem(new ECSPlayerAtkSystem(contextManager)) // 添加角色攻击系统
            .addSystem(new ECSInputSystem(contextManager)) // 添加输入监听系统;
        return sysMgr;
    }
  • 接下来就是对system的控制

    这是我的对系统输入的监控脚本RockerComp

    start = () => {
        this.moveListenOpen();
    }
    initData = () => {
    
    }
    update(dt) {
        this.moveController(dt);
    }
    // 开启移动监听
    moveListenOpen = () => {
        this.node.on(cc.Node.EventType.TOUCH_START, this.onMoveTouchStartCallback, this);
        this.node.on(cc.Node.EventType.TOUCH_MOVE, this.onMoveTouchMoveCallback, this);
        this.node.on(cc.Node.EventType.TOUCH_END, this.onMoveTouchEndCallback, this);
        this.node.on(cc.Node.EventType.TOUCH_CANCEL, this.onMoveTouchCancelCallback, this);
    }
    // 关闭移动监听
    moveListenClose = () => {
        this.node.off(cc.Node.EventType.TOUCH_START, this.onMoveTouchStartCallback, this);
        this.node.off(cc.Node.EventType.TOUCH_MOVE, this.onMoveTouchMoveCallback, this);
        this.node.off(cc.Node.EventType.TOUCH_END, this.onMoveTouchEndCallback, this);
        this.node.off(cc.Node.EventType.TOUCH_CANCEL, this.onMoveTouchCancelCallback, this);
    }
    // 移动开始触摸回调
    onMoveTouchStartCallback = (event) => {
        if (this.moveControlId < 0) { // 控制单个移动事件监听
            this._controllerTrigger = true;
            this.node.opacity = 255;
            this.moveControlId = event.getID();
            let location = event.target.convertToNodeSpaceAR(event.getLocation());
            this.rockerPositionHandler(this.rocker, location, true);
        }
    }
    // 移动触摸移动回调
    onMoveTouchMoveCallback = (event) => {
        if (event.getID() === this.moveControlId) {
            let location = event.target.convertToNodeSpaceAR(event.getLocation());
            this.rockerPositionHandler(this.rocker, location);
        }
    }
    // 移动触摸结束回调
    onMoveTouchEndCallback = (event) => {
        if (event.getID() === this.moveControlId) {
            this.moveControlId = -1;
            this._controllerTrigger = false;
            // this.moveEndCallFunc(this);
            this.rockerReset(this.rocker); // 虚拟操控感复位
        }
    }
    // 移动取消触摸回调
    onMoveTouchCancelCallback = (event) => {
        if (event.getID() === this.moveControlId) {
            this.moveControlId = -1;
            this._controllerTrigger = false;
            // this.moveEndCallFunc(this);
            this.rockerReset(this.rocker); // 虚拟操控感复位
        }
    }
    // 虚拟摇杆
    // 摇杆位置操作
    rockerPositionHandler(node, touchPos, isFirst = false): void {
        let moveVector = touchPos.sub(node.position); // 移动向量
        let touchToCenterDistance = moveVector.mag(); // 触摸点距离摇杆中心距离
        let moveRadian = Math.atan2(moveVector.x, moveVector.y); // 移动向量弧度
        let moveAngle = moveRadian * 180 / Math.PI; // 移动向量角度
        if (isFirst && touchToCenterDistance > this.rockerMoveRadius) { // 第一次触摸调整摇杆位置
            let beyondDistance = touchToCenterDistance - this.rockerMoveRadius;
            let newPos = cc.v2(node.position.x + beyondDistance * Math.sin(moveRadian), node.position.y + beyondDistance * Math.cos(moveRadian));
            node.setPosition(newPos);
            moveVector = touchPos.sub(node.position); // 移动向量
            touchToCenterDistance = moveVector.mag(); // 触摸点距离摇杆中心距离
        }
        let directorPos = cc.v2(this.directorRadius * Math.sin(moveRadian), this.directorRadius * Math.cos(moveRadian));
        if (touchToCenterDistance > this.joyStickMoveRadius) {
            let newPos = cc.v2(this.joyStickMoveRadius * Math.sin(moveRadian), this.joyStickMoveRadius * Math.cos(moveRadian));
            this.joyStick.setPosition(newPos);
        } else {
            let newPos = cc.v2(touchToCenterDistance * Math.sin(moveRadian), touchToCenterDistance * Math.cos(moveRadian));
            this.joyStick.setPosition(newPos);
        }
        if (this.rockerDirector) {
            this.rockerDirector.active = true;
            this.rockerDirector.setPosition(directorPos);
            this.rockerDirector.angle = -moveAngle;
        }
    }
    // 操控杆恢复初始设置
    rockerReset(node): void {
        this.rocker.setPosition(this.rockerInitPos);
        this.joyStick.setPosition(cc.v2(0, 0));
        this.node.opacity = 100;
        if (this.rockerDirector) {
            this.rockerDirector.active = false;
        }
    }
    
    // 控制单位移动(这里可以自己实现不同的节点移动效果)
    moveController(dt): void {
        let moveVector = this.joyStick.position.sub(cc.v2(0, 0)); // 操控杆移动向量
        // let moveLength = moveVector.mag(); // 操控杆移动距离
        CKNotification.getInstance().notify(NOTIFY_EVENT_ENUM.JOY_STICK, moveVector); // 这个通知很重要
        // if (moveLength > 0) {
        // }
    }
    /** 移动开始回调 */
    moveStartCallFunc(self): void {
        if (!self.beControlledNode) return;
        self.beControlledNode.ins.moveStartCallFunc(self.joyStick.position);
    }
    /** 移动结束回调 */
    moveEndCallFunc(self): void {
        if (!self.beControlledNode) return;
        self.beControlledNode.ins.moveEndCallFunc(self.joyStick.position);
    }
    

    这是监听这个事件的脚本ECSInputSystem

    _flag = ECS_SYSTEM_ENUM.ECS_INPUT_SYSTEM;
    private _contextManager: ECSContextManager;
    
    public get contextManager(): ECSContextManager {
        return this._contextManager;
    }
    public set contextManager(value: ECSContextManager) {
        this._contextManager = value;
    }
    
    constructor(contextManager: ECSContextManager) {
        super();
        CKNotification.getInstance().on(NOTIFY_EVENT_ENUM.INPUT_OPERATION, this.onInputCallFunc, this);
        CKNotification.getInstance().on(NOTIFY_EVENT_ENUM.CANCEL_OPERATION, this.onInputCancelCallFunc, this);
        CKNotification.getInstance().on(NOTIFY_EVENT_ENUM.JOY_STICK, this.onJoyStick, this);
        this.contextManager = contextManager;
    }
    
    execute(dt?: number) {
    
    }
    
    onInputCallFunc(notify, [flag, target]): void {
        cc.log(flag, ' was be clicked.');
        this.addInputCode(flag);
    }
    onInputCancelCallFunc(notify, [flag, target]): void {
        cc.log(flag, ' was be cancel.');
        this.removeInputCode(flag);
    }
    
    addInputCode(flag: number): void {
        let inputCompList = this.contextManager.game.getComponentListByComponentType(ECS_COMP_ENUM.ECS_INPUT);
        for (let comp of inputCompList) {
            comp.value |= flag;
            cc.log(comp);
        }
    }
    
    removeInputCode(flag: number): void {
        let inputCompList = this.contextManager.game.getComponentListByComponentType(ECS_COMP_ENUM.ECS_INPUT);
        for (let comp of inputCompList) {
            comp.value ^= flag;
            cc.log(comp);
        }
    }
    
    onJoyStick(notify, [vec]): void {
    
        let contextManager: ECSContextManager = ECSContextManager.getInstance(); // 获取世界实例
        let entityList: Array<ECSEntity> = contextManager.game.getEntityHasAllCompTypes(ECS_COMP_ENUM.ECS_INPUT, ECS_COMP_ENUM.ECS_VELOCITY); //获取需要的实例
        for (let entity of entityList) {
            let comp: ECSVelocityComponent = contextManager.game.getComponent(ECS_COMP_ENUM.ECS_VELOCITY, entity);
            let moveVec: cc.Vec2 = vec.normalizeSelf();
            comp.value = moveVec.mulSelf(comp.speed);
        }
    
    }
    

    然后,关系位置组件的系统就改变了实例位置 ECSChangePositionSystem

     _flag = ECS_SYSTEM_ENUM.ECS_CHANGE_PLAYER_POS_SYSTEM;
    
    private _contextManager: ECSContextManager;
    
    public get contextManager(): ECSContextManager {
        return this._contextManager;
    }
    public set contextManager(value: ECSContextManager) {
        this._contextManager = value;
    }
    
    constructor(contextManager: ECSContextManager) {
        super();
        this.contextManager = contextManager;
    }
    
    execute(dt?: number): void {
        let entityList: Array<ECSEntity> = this.contextManager.game.getEntityHasAllCompTypes(ECS_COMP_ENUM.ECS_NODE, ECS_COMP_ENUM.ECS_POSITION, ECS_COMP_ENUM.ECS_VELOCITY);
        for (let entity of entityList) {
            let velocityComp = entity.getComponent(ECS_COMP_ENUM.ECS_VELOCITY);
            let posComp = entity.getComponent(ECS_COMP_ENUM.ECS_POSITION);
            let nodeComp = entity.getComponent(ECS_COMP_ENUM.ECS_NODE);
            posComp.value.addSelf(velocityComp.value);
            nodeComp.node.position = posComp.value;
        }
    }
    

结束

  • 这是我第三次发帖,主要是自己不太明白想和大家交流一下这个关于ECS和Cocos结合的方案
  • 代码参考5楼的大佬GitHub就可以了~
  • 目前对于ECS就是感觉维护和整理很方便。
  • 希望有大佬可以指点迷津。
5赞

不知道你想干啥。。。。CocosCreator本身就是ecs的,entity就是cc.Node,component就是cc.Component

CC不是ECS,它跟Unity一样是GameObject-Component系统。

如果通过节点绑定component脚本的话, 就对节点的依赖性会很强,我对ecs的理解是,entitiy只是一个数据结构,用来关联组件component。一个entity是可以没有节点的,依赖节点在我的观点里不是ecs的框架设计理念。当然这是我个人的看法,嘿嘿嘿。:yum:

参考Entitas精简的
https://github.com/shangdibaozi/ECS

3赞

wow,大佬!太帅了,这就是我想要的,读完代码之后,再请教你一些问题~

太帅了:+1::+1::+1:

之前我也基于 Entitas移植了一个cocos的ecs,实际用下来发现ecs最大的难点在于system的运行顺序和system的粒度。
运行顺序在system多了以后经常会产生冲突,尤其是一个sys基于另一个sys的运行结果,如果依赖形成了环会很麻烦。
粒度则是很多时候为了更抽象,会想把功能拆分的经可能小,但是这样在维护所有sys的时候会很麻烦,开发起来也很累,还会导致更多的sys之间的冲突。

2赞

ecs用system控制移动,那么tween这种东西是不是就不能用了

system之间顺序是关键,一旦完善顺理好,那就很安逸

ecs最大的特点是支持多线程, 但cocos还没有一个好的方案使用多线程

也可以的,之前还写了tween的sys,可以把tween的状态记录到component里面,比如当前tween动画移动需要几毫秒,处于第几毫秒

ECS最大的特点是要榨干多核CPU,也就是体现在多线程上。而且处理的数据还应该都是值类型。
而cocos creator这两点都很难满足,并不适合。

ECS这种设计模式写游戏的战斗部分真的好用