实现一个简单的 H5 游戏框架 (持续更新中)

1,133 阅读6分钟

最近用 Pixi.js 和 Matter.js 攒了一个简单的 H5 游戏框架。具体代码可以看看 github.com/Hierifer/cy… 代码没有整理。以学习分享的目的开发,有任何建议和想法欢迎沟通。

DEMO

通过 cyberhunter.neo-hex.com 可以试玩基于这个框架做的 Demo。Demo 比较杂乱是「合成大西瓜」+「横版跳跃」的混合体。因为项目主要目的是实现游戏框架能力而非 gameplay 的部分,在完善框架的结构后,会实现几个经典的游戏案例。

image.png

Demo 支持了 Spine2d 动画,可以通过「上下左右」键移动。也可以点击上方区域生成水果。哈哈,硬缝一下,借鉴幻兽帕鲁。

谈谈游戏框架的整体设计

框架思路主要借鉴 Unity,Cocos 等现代游戏引擎。概括来讲分为 Gameplay(游戏玩法)和 Engine(框架)部分。这种分离式设计的好处在于复用性极高,开新项目尤其是工业化生产时。直接使用 Engine 的部分加新的 Gameplay 逻辑就可以量产某一类型的游戏。比如,早期的起源引擎(source)在 FPS 类游戏就有大量的相关优化,如地图树等。

image.png

Gameplay(玩法层)

在游戏玩法中,定义了游戏控制器,游戏预制件(Prefab),和一些玩法逻辑脚本,UI 脚本。其中逻辑脚本会以组件的形式插入到游戏对象中,引擎层提供的两个 onStart 和 onUpdate 两个主要的 hooks 让玩法逻辑参与到引擎的声明周期中。

class CustomComponent extends Component {
    ...
    
    onStart(){
        // 初始化对象,行为,副作用
    }
    
    onUpdate(){
        // 更新对象
    }
}

当然引擎会可以预置一些组件,包括物理组件,着色器组件,2D 精灵图组件,动画组件,帮助玩法层快速开发。但所有的组件都会继承 Component 这个类并实现 onStart 和 onUpdate。

Engine (引擎层)

引擎大致由以下几个部分组成: Game (游戏核心流程管理),Physics (物理对象管理),Scene(游戏对象和组件),Audio(声音的封装)。其实做的事情比较简单,基本上就是在 Pixi.js, Matter.js,和 Howler.js 的基础上封装了一层管理层。这一层主要解决以下几个点:

  1. 何时同步物理引擎和渲染引擎
  2. 怎么查找和通信游戏对象
  3. 玩法层如何参与的事件循环中
  4. 如何使用资源
  5. ...

在后面谈一下我的思考和实现。说说引擎相对为直接使用 Pixi.js,这个框架核心解决的三件事:

  1. 管理游戏对象:建立组合式的预制件管理。增加了游戏对象的复用性。支持了更加复杂的游戏场景。
  2. 模块变得可插拔:增加物理引擎,让渲染引擎具有第三方物理加成,而且这种集成被解耦了出来。
  3. 解构,复用,工程化:分离了 Gameplay,增加了项目的复用性。如果开发下一个小游戏,直接使用 Engine 的部分即可。

核心生命周期

生命周期的设计解释了何时同步物理引擎和渲染引擎

引擎框架来主动推动整体的状态流转,玩法层的逻辑会通过 Hooks 被插入的整个引擎中来。比如,下图展示了引擎启动,带动 Gameplay 中的 Hooks 启动的流程。

而 Gameplay 基于框架的 Hook 在开发。

image.png

明确的主从关系的好处在于

1)框架时刻控制游戏整体的进程,不会出现依赖对象未实例化的情况。比如,游戏对象已经完成加载,但物理引擎还未 ready 的情况,这是可能有 undefined 错误产生

2)每个游戏对象所处的状态都是一样的,不会出现有对象没有更新状态的情况。比如,漏到一些游戏对象的位置更新

更新周期

我们看看更新阶段(onUpdate)的代码。框架采取先执行物理更新碰撞更新游戏玩法更新,然后触发 Debug 的信号用于 Debug。

  async update() {
    // 游戏逻辑之后移除
    let lastDetecor = 0;

    this.app.ticker.add(async (time) => {
      // onUpdateBeforePhysics();

      // 物理自动更新
      // 第一次定位由物理同步,如需手动需设置 init 为 true
      const updatedPhyMap = this.phyManager.getUpdatedMap();

      for (let i = 0; i < this.goManager.length; i++) {
        const curGO = this.goManager[i];
        const phybody = curGO.findComponent("physics2d");
        if (
          phybody &&
          phybody instanceof Physics2DComponent &&
          (phybody.isStatic || phybody.isSleeping)
        ) {
          continue;
        }
        curGO.setGeo({
          pos: updatedPhyMap.get(curGO.id)?.pos || { x: 0, y: 0 },
          rot: updatedPhyMap.get(curGO.id)?.angle || 0,
        });
      }
      updatedPhyMap.clear();

      // 物理碰撞更新:开发者逻辑
      const detectPeriod = Math.floor(time.lastTime / 50);
      if (detectPeriod !== lastDetecor) {
        [...this.collisionEffect.keys()].forEach((space) => {
          const col = this.phyManager.triggerDetector(space);
          const effect = this.collisionEffect.get(space);
          if (effect) effect(col);
        });
        lastDetecor = detectPeriod;
      }

      // Gameplay 逻辑
      await onUpdate(this, time);

      // Debug 信号
      this.debugSignal(time.lastTime);
    });
  }

为什么物理更新在 Gameplay 之前

Gameplay 是开发者主关心的一个逻辑。在逐帧 Debug 时,Gameplay 对对象的修改会是更符合开发者直觉的。如果放在更新物理前,渲染出来的不是开发者想象中的位置,导致 Debug 上的错误判断。所以 Gameplay 的更新被设计成渲染前最后一次更新对象位置的操作。当然 Profile 和 Debug 等读操作被设计 Gameplay 之后,这有利于知道这一帧的实际结果。

ECS:组合而非继承

GameObject 使用为什么组合而非继承?因为继承对于「相似物体」的复用率不高。

举个例子,国防部要采购一批无人机,我们要写一个数据结构表示这批无人机。军方要求要有摄像头,这样无人机变成了侦察无人机。军方又加上火箭筒,成了武装无人机。两个都加上,变成察打一体的无人机。那么我们怎么通过继承来设计这个察打一体的无人机?可能我们需要一个比较复杂的继承关系才能表示。

但组合设计会更加灵活。

image.png

ECS(Entity Component System)是组合而非继承的实现

  // /prefab/fruit.ts
  
  new GameObject(
    label ?? "cherry",
    { posX: x, posY: y },
    { width: this.sprite!.width, height: this.sprite!.height },
    "fruit"
  ).addComponents([
    new SpriteComponent(this.sprite),
    new Physics2DComponent(box),
    new Physics2DColliderComponent(space || "fruit", box),
  ])

我们可以定义一个水果,这个水果可以使用自己精灵图,可以有自己的物理刚体,可以有自己任何特性。

技术栈的选择

为什么选择 Pixi.js 加 Matter.js?在之前的探索中发现 Pixi 和 Matter 对于跨端的支持是比较好的。Box2D 在物理方面也不错,在部分场景优于 Matter。但在 iOS 场景下,存在部分性能问题(博主未实测,下方图片来自微信开放平台的博主评测 @袁梓民)。

image.png

(数据来源于 JavaScript物理引擎之Matter.js与Box2d性能对比)

所以为了跨端能力选择了这两个技术栈。包括 Howlerjs 对于跨端能力支持的也不错。个人认为 H5 游戏是在开发难度和跨端能力的最优解。

在 Engine 的 build 文件,我就尝试通过使用 Flutter 将游戏直接移植到移动端和客户端。效果很不错。

欢迎小伙伴可以下载玩玩~