最近用 Pixi.js 和 Matter.js 攒了一个简单的 H5 游戏框架。具体代码可以看看 github.com/Hierifer/cy… 代码没有整理。以学习分享的目的开发,有任何建议和想法欢迎沟通。
DEMO
通过 cyberhunter.neo-hex.com 可以试玩基于这个框架做的 Demo。Demo 比较杂乱是「合成大西瓜」+「横版跳跃」的混合体。因为项目主要目的是实现游戏框架能力而非 gameplay 的部分,在完善框架的结构后,会实现几个经典的游戏案例。
Demo 支持了 Spine2d 动画,可以通过「上下左右」键移动。也可以点击上方区域生成水果。哈哈,硬缝一下,借鉴幻兽帕鲁。
谈谈游戏框架的整体设计
框架思路主要借鉴 Unity,Cocos 等现代游戏引擎。概括来讲分为 Gameplay(游戏玩法)和 Engine(框架)部分。这种分离式设计的好处在于复用性极高,开新项目尤其是工业化生产时。直接使用 Engine 的部分加新的 Gameplay 逻辑就可以量产某一类型的游戏。比如,早期的起源引擎(source)在 FPS 类游戏就有大量的相关优化,如地图树等。
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 的基础上封装了一层管理层。这一层主要解决以下几个点:
- 何时同步物理引擎和渲染引擎
- 怎么查找和通信游戏对象
- 玩法层如何参与的事件循环中
- 如何使用资源
- ...
在后面谈一下我的思考和实现。说说引擎相对为直接使用 Pixi.js,这个框架核心解决的三件事:
- 管理游戏对象:建立组合式的预制件管理。增加了游戏对象的复用性。支持了更加复杂的游戏场景。
- 模块变得可插拔:增加物理引擎,让渲染引擎具有第三方物理加成,而且这种集成被解耦了出来。
- 解构,复用,工程化:分离了 Gameplay,增加了项目的复用性。如果开发下一个小游戏,直接使用 Engine 的部分即可。
核心生命周期
生命周期的设计解释了何时同步物理引擎和渲染引擎
引擎框架来主动推动整体的状态流转,玩法层的逻辑会通过 Hooks 被插入的整个引擎中来。比如,下图展示了引擎启动,带动 Gameplay 中的 Hooks 启动的流程。
而 Gameplay 基于框架的 Hook 在开发。
明确的主从关系的好处在于
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 使用为什么组合而非继承?因为继承对于「相似物体」的复用率不高。
举个例子,国防部要采购一批无人机,我们要写一个数据结构表示这批无人机。军方要求要有摄像头,这样无人机变成了侦察无人机。军方又加上火箭筒,成了武装无人机。两个都加上,变成察打一体的无人机。那么我们怎么通过继承来设计这个察打一体的无人机?可能我们需要一个比较复杂的继承关系才能表示。
但组合设计会更加灵活。
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 场景下,存在部分性能问题(博主未实测,下方图片来自微信开放平台的博主评测 @袁梓民)。
(数据来源于 JavaScript物理引擎之Matter.js与Box2d性能对比)
所以为了跨端能力选择了这两个技术栈。包括 Howlerjs 对于跨端能力支持的也不错。个人认为 H5 游戏是在开发难度和跨端能力的最优解。
在 Engine 的 build 文件,我就尝试通过使用 Flutter 将游戏直接移植到移动端和客户端。效果很不错。
欢迎小伙伴可以下载玩玩~