CocosCreator 游戏开发 - 利用对象池优化运行时性能

402 阅读5分钟

上一篇关于 cocos creator 的文章当中,大家了解到我这个业余的游戏开发者之前使用了 cocos creator 制作了一个类挂机割草爽感塔防的游戏。

众所周知,想要做好一个割草类游戏的良好的爽感,怪物敌人数量不能少,只有足够多数量的敌人,才能够给玩家带来更爽快的割草感受。在游戏的实际运行逻辑当中,不停歇的创建和销毁对象是非常消耗资源和运行性能的,而割草塔防类游戏正好游戏运行逻辑就会反复的生成和销毁发射的子弹以及大量敌人的生成和被射击死亡销毁,因此为了提高游戏在这方面的运行性能,我们这里引入到一个“对象池”的概念。

对象池的概念

下面该链接是 Coccs Creator 游戏引擎当中 NodePool 对象池的原生支持的 API。

使用对象池 · Cocos Creator

对象池的主要属性和操作方法:

import { NodePool } from 'cc';

// 实例化对象池处理 - 实例化对象池需要传入区分关键字或者传入对应的一个预制体 Component
const nodePool = new NodePool('PrefabName');
const nodePool = new NodePool(PrefabComponent);

// 获取对象池当中预制体实例节点数量
nodePool.size();

// 将预制体实例对象加入对象池当中
nodePool.get(PrefabNode);

// 从对象池当中获取空闲的的预制体实例对象
nodePool.get();

// 清除对象池中的缓存预制体实例
nodePool.clear();

受到对象池管控的预制体会有对应特殊的生命周期钩子:

  • new NodePool 创建对象池存储实例时候如果传入的是 PrefabComponent 则在会根据对象池节点回收和和复用的场景调用以下两个对象池专属的钩子回调。
unuse() {
  // 预制体实例节点回收时候自动调用
  ... // 可以进行对事件的解绑或者物理引擎、动画系统的暂停等操作
},

reuse() {
  // 预制体实例节点复用重新激活时候自动调用
  ... // 可以进行对事件的重新绑定或者物理引擎、动画系统的重新启用等操作
}

其他关于 Cocos Creator 当中NodePool 对象池的技术细节的描述这里就不再累赘复述了,各位请酌情移步至官方文档当中进行查阅。

对象池的封装

但是因为对象池作为整个游戏系统当中全局运行的,我们应该进一步封装,将相关的对象池存储、操作都统一起来,形成良好的编码习惯。

  • 将整个系统的对象池封装整合统一获取存储使用;
  • 并且结合着饿汉式单例模式的设计模式来对对象池进行优化封装;
import { instantiate, NodePool, Prefab, Node } from 'cc';

export default class NodePoolManager {
  static _instance: NodePoolManager = new NodePoolManager();

  private _nodePoolMap: Map<string, NodePool>;

  constructor() {
    this._nodePoolMap = new Map<string, NodePool>();
  }

  static destroyInstance() {
    this.instance.clearAll();
    this._instance = null;
  }

  /**
   * @func 通过对象池名字从容器中获取对象池
   * @param name 对象池名字
   * @return 对象池
   */
  private getNodePoolByName(name: string): NodePool {
    if (!this._nodePoolMap.has(name)) this._nodePoolMap.set(name, new NodePool(name));
    return this._nodePoolMap.get(name);
  }

  /**
   * @func 通过对象池名字从对象池中获取节点
   * @param name 对象池名字
   * @param prefab 可选参数,对象预制体
   * @return 对象池中节点
   */
  public getNodeFromPool(name: string, prefab?: Prefab): Node {
    const nodePool = this.getNodePoolByName(name);
    const poolSize = nodePool.size();
    if (poolSize <= 0) nodePool.put(instantiate(prefab));
    return nodePool.get();
  }

  /**
   * @func 将节点放入对象池中
   * @param name 对象池名字
   * @param node 节点
   */
  public putNodeToPool(name: string, node: Node) {
    const nodePool = this.getNodePoolByName(name);
    nodePool.put(node);
    node.active = false;
    node.removeFromParent();
  }

  /**
   * @func 清空所有对象池
   */
  public clearAll() {
    this._nodePoolMap.forEach((value: NodePool) => value.clear());
    this._nodePoolMap.clear();
  }
}

对象池的应用

结合着我们的塔防游戏进行对象池技术的接入,主要是将对象池应用到子弹以及怪物敌人的生成、销毁优化上了。

首先是怪物敌人:

主要是生成和被子弹碰撞击杀销毁两处的两处地方进行调整处理;

  • 怪物生成时候逻辑:
import NodePoolManager from "db://assets/runtime/NodePoolManager";

const {ccclass, property} = _decorator;

@ccclass('EnemyBoxController')
export class EnemyBoxController extends Component {
    @property(Node)
    SampleEnemy: Prefab;
    @property(Node)
    EnemiesSite: Node;

    async onEnable() {
        // 触发钩子开始生成怪物
        this.renderEnemies();
    }

    renderEnemies() {
        // 处理获取相关怪物配置逻辑
        const {enemy = 1, amount, space} = config || {};
        const commonEnemyConfig = enemiesConfig[enemy - 1] || {};

        for (let i = 0, amount; i++) {
          
            // 生成新的怪物时候就使用对象池封装的获取缓存节点的方法,
            // 		内部逻辑已经兼容了没空闲的缓存节点则 instantiate 一个新的节点对象
            const enemyNode = NodePoolManager.instance.getNodeFromPool(`Enemy-${enemy}`, this.SampleEnemy);

            // getNodeFromPool 返回的是一个 Node 节点,可以进行后续根据配置重新初始化以及放置到渲染层级的操作
            const EnemyNodeController = enemyNode.getComponent('EnemyController');
            EnemyNodeController.initEnemy({index: enemy, ...config, ...commonEnemyConfig,}, new Vec2(getRandomInt(-295, 295), 580 + i * (space || 35)));
            this.EnemiesSite.insertChild(enemyNode, 0);
        }
    }
}
  • 怪物被击败销毁
import NodePoolManager from "db://assets/runtime/NodePoolManager";

@ccclass('EnemyController')
export class EnemyController extends Component {
    private _collider: Collider2D = null;
    private _rigidBody: RigidBody2D = null;

    onLoad() {
        this._collider = this.getComponent(Collider2D);
        this._rigidBody = this.getComponent(RigidBody2D);

        // 开始监听碰撞 - 这里我们直接使用物理引擎进行碰撞检测处理
        if (this._collider) {
            this._collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
            this._collider.on(Contact2DType.END_CONTACT, this.onEndContact, this);
        }
    }

    onBeginContact(selfCollider: Collider2D, otherCollider: any) {
      ... // 碰撞接触开始

      // 判断血量等操作 - 忽略

      
      ... 
      // 这里直接当怪物血量为 0,怪物被击杀后进行怪物的节点隐藏,刚体、动画等停用操作
      // 显示怪物爆炸效果等

      // 然后需要调用 putNodeToPool 进行对象池缓存节点的回收操作
      NodePoolManager.instance.putNodeToPool(`Enemy-${this._index}`, this.node);
    }

    onEndContact(selfCollider: Collider2D, otherCollider: any) {
      ... // 碰撞接触结束
    }

    async initEnemy(...) {
      // 初始化怪物信息
    }
}

接着是子弹逻辑:

子弹和怪物敌人除了生成和碰撞销毁这两处共同逻辑外,还有则是需要在子弹运行到场景边界外(或视线外)需要进行对子弹进行销毁回收处理;

  • 子弹生成逻辑基本同怪物,都是调用NodePoolManager.instance.getNodeFromPool获取(或者是新生成一个)空闲对象池缓存节点;而碰撞后销毁也是类似,在监听碰撞接触事件回调后调用NodePoolManager.instance.putNodeToPool将节点推回对象池当中作为空闲节点处理。
  • 子弹运行到场景边界外进行销毁回收节点的逻辑其实也是调用NodePoolManager.instance.putNodeToPool
    • 但是这里简单扩展下子弹跨越边界判断的三种方法,
      • 一种是不断在 update 方法当中判断子弹的 position 进行处理;
      • 一种则是在边界当中也做一个刚体碰撞检测的方法;
      • 还有一种则是大约计算好相关的子弹轨迹,直接计算多少时长后就会越界,设置定时器进行清除;
    • 这里就不再过多的描述这方面如何检测子弹越界(毕竟这文章是关于对象池的应用的),有机会再深入探讨或者评论区发表下大佬的独特见解。

在每一关卡结束时候,将本次关卡的所有子弹和所有的怪物的对象池缓存节点都进行一次性全部清理:

  • 在前面对象池的封装当中,我们已经有封装一个一次性清理全部对象池的操作方法,这里就直接在对应时机逻辑当中执行即可。
import NodePoolManager from "db://assets/runtime/NodePoolManager";

handleGameFinish() {
  ... // 游戏结束逻辑
  NodePoolManager.instance.clearAll();
}

这里给自己开发的一个微信小游戏做下引流,微信搜索 “坦克幸存者” 或者来扫下面的小游戏码多来玩玩小游戏来支持下这位可怜贫困的前端搬砖仔。

wechat-search.png