简介
本文用于记录微信小游戏开放之后,自己学习工作之余的娱乐练手项目,便于将来回顾与参考,其为一个简单的跑酷小游戏,直接基于h5开发,Github地址为: Doge-Jump
本文将分为以下几个章节做记录:
- 微信小游戏介绍
- 开发准备
- 基础框架
- 游戏逻辑
- 总结
注:本文完成时,业已发现原代码中存在的各种 bug 和缺陷,由于疾病(懒癌)的关系,不便于再做修改,故原代码只提供参考价值,注明于此。
微信小游戏介绍
继微信小程序之后,微信开放了小游戏的接口,其随开放带来的跳一跳小游戏在一夜之间攻占了整个朋友圈,也体现了目前微信小游戏的特点与定位:简单、休闲、魔性。
微信小游戏基于 HTML5 平台,使用 JavaScript 语言开发,可以直接通过 HTML5 的接口进行开发,也可以使用现成的 HTML5 开发引擎,例如 cocos2d 、 egret 等。
开发准备
好(xian)奇(de)之(dan)下(teng),我开始接触小游戏的开发,通过微信官网的文档 微信小游戏开发 可以找到开发工具的下载和开发文档(在我刚开始尝试微信小游戏的开发时,还并没有小游戏的文档,现在微信官方已补全,故可通过官方文档快速上手小游戏)
按照官方文档中提供的介绍,下载开发工具安装,即可直接进入小游戏的开发界面:
小游戏开发者工具的最主要的功能就是用于编译和预览(当然还有发布等等),为了照顾个人习惯,实际上平常代码的编辑我还是使用 sublime text 进行的,所以一般的开发流程就是,在编辑器中把代码码好,然后在开发者工具中预览即可。
基础框架
接下来就是进行小游戏的开发,由于没有使用现有的引擎(不要问我为什么,当时太年轻),以及直接使用 H5 的接口开发非常的不好用,所以需要对原生接口进行封装,抽象出一套比较好用的游戏开发接口,然后再进行游戏逻辑的开发。
这套接口的提供的主要功能有以下几种:
- GameManager :游戏主管理器,存储游戏全局数据,进行全局设置,控制游戏的开始等等
- EventManager :事件管理,基于观察者模式的事件分发系统
- InputManager :游戏输入控制,主要是触屏输入
- SceneManager :场景管理,场景切换等功能
- PhysicsManager :简易的物理引擎,处理碰撞检测等
- MusicManager :音效管理
- ActionManager :Action 系统,其实是一套类似 Tween 动画的动画系统
而游戏中的基本组件有:
- Node :游戏中的基本元素,类似 Unity 中的 GameObject ,cocos2d 中的 Node 。是游戏中的基础节点,用于构造整个游戏场景的结构
- Sprite :精灵,继承自 Node ,游戏中最基础的渲染节点,可以加载图片资源进行显示
- Animation :动画,继承自 Sprite ,简易的帧动画实现
- Vector,Rect :基础数据结构,比如 Vector 可用于大小、位置的存储, Rect 用于包围盒的描述等
- Selectable,Button,Text :基础的 UI 实现,Selectable 是一个基础类,需要接收点击事件的 UI 组件(例如 Button )继承于此
- Logger :日志,虽然并没有任何管理功能。。。
游戏入口
小游戏提供了一个统一的入口,即 js/main.js 文件,在这里,游戏框架做了各个管理器的初始化工作,并搭建了基本的游戏主循环,控制了游戏逻辑、渲染、物理等各个模块的更新:
// js/main.js
// 初始化
restart() {
this.managers = [];
this.managers.push(GameManager.instance);
this.managers.push(SceneManager.instance);
this.managers.push(ActionManager.instance);
this.managers.push(EventManager.instance);
this.managers.push(MusicManager.instance);
this.managers.push(InputManager.instance);
this.managers.push(PhysicsManager.instance);
this.managers.forEach(function(mgr) {
mgr.restart();
});
// 设计缺陷,使用到的场景必须先在 SceneManager 里注册
SceneManager.instance.addScene("MainScene", function() {
return new MainScene();
});
SceneManager.instance.addScene("PlayScene", function() {
return new PlayScene();
});
SceneManager.instance.switchToScene("MainScene");
// 启动游戏循环
window.requestAnimationFrame(this.loop.bind(this), canvas);
}
// 主循环
loop() {
if (!this.lastTime) {
this.lastTime = new Date().getTime();
}
this.curTime = new Date().getTime();
// 循环
this.update((this.curTime - this.lastTime) / 1000);
this.lateUpdate();
this.render();
this.lastTime = this.curTime;
// 场景管理:是否结束游戏
if (SceneManager.instance.quitGame) {
this.restart();
return;
}
window.requestAnimationFrame(this.loop.bind(this), canvas);
}
// 逻辑更新,部分管理器之间的更新顺序是有要求的
update(dt) {
GameManager.instance.update(dt);
PhysicsManager.instance.update(dt);
InputManager.instance.update(dt);
SceneManager.instance.update(dt);
ActionManager.instance.update(dt);
}
// 逻辑更新之后的 late 更新,用于处理一些特殊逻辑
lateUpdate() {
SceneManager.instance.lateUpdate();
}
// 渲染
render() {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, GameManager.instance.screenWidth, GameManager.instance.screenHeight);
SceneManager.instance.render(ctx);
}
以上有注释,就不做具体说明。
场景结构
由于早期使用 cocos2d 挺长一段时间,研究了一会它的代码,所以这里搭建的简易框架风格也跟 cocos2d 有点相似,比如场景为树形结构,类似于:
|-- Scene
|-- BackgroudLayer
|-- Mountain
|-- House
|-- Sky
|-- PlaygroudLayer
|-- Player
|-- ForegroudLayer
|-- Tree
|-- UILayer
|-- Score
|-- Money
上面每一个物体都是一个 Node ,Node 之间的层级设置通过 AddChild 操作来实现,例如:
export default class MainScene extends Scene {
constructor() {
super();
// 添加一个 Button
let startBtn = new Button('images/ui/BtnNormal.png', 'images/ui/BtnSelected.png', 128, 64, function(point) {
// Button 点击时切换场景为 PlayScene
SceneManager.instance.switchToScene("PlayScene");
});
this.addChild(startBtn); // 将 Button 添加为 MainScene 的子节点
startBtn.position = new Vector2(100, 100);
// 给 Button 添加 Text
let startBtnName = new Text('START', 100, "#000000", "middle");
startBtn.addChild(startBtnName); // 将 Text 添加为 StartBtn 的子节点
startBtnName.position = new Vector2(0, 0);
}
上面通过两次 AddChild 操作,形成的场景结构为:
|-- MainScene
|-- StartBtn
|-- StartBtnName
显示效果为:
其中可以看出 Button 位于 Scene 上,Text 位于 Button 上的显示层级。
通过父子节点的形式来构成场景的好处有几个:
- 解决坐标系问题:如果有父子节点,子节点的位置定义是就不必使用世界坐标,而是使用相对父节点的相对坐标,更易于使用和理解
- 解决渲染顺序问题:通过特定顺序(例如前序)遍历场景树,可以形成一个有序的队列,通过这个队列可以很明确地定义渲染的顺序,表现为父节点必定比子节点先渲染,兄弟节点间在前面的先渲染
- 场景划分易于理解,比如场景中可以分作几个层,可以明显看出每个层之间的前后关系,逻辑上比较容易让人接受
- 等等
Node 设计
Node 是框架中的基本元素,所有游戏中的组件都是 Node,Node 复杂了许多方面的工作,例如前面提到的构成场景结构,还有生命周期管理,坐标计算,碰撞事件等等。
先从生命周期讲起,Node 有这么几个生命周期:
- constructor :初始化,Node 在创建时调用,仅会调用一次
- update :逻辑更新,如果当前 Node 在被激活的场景中,则会每帧更新
- render :渲染更新,Sprite 及其子类会被调用,在 update 之后,一般不需要手动处理
- onEnable :Node 每次启动的时候调用
- onDisable :与 onEnable 相对,同时在 destroy 的时候也会被调用
坐标计算方面,主要分为相对坐标以及世界坐标。
- 相对坐标(local position):即当前 Node 相对其父节点的位置
- 世界坐标(world position):当前 Node 在世界坐标系中的位置
例如父节点坐标为(100,100),子节点相对坐标为(100,0),则子节点世界坐标为(200,100);
使用两种坐标的好处是子节点的位置更容易理解(只要相对其父节点结算即可),另一方面是当父节点改变时,子节点的坐标可以非常方便的进行计算
碰撞事件,为了方便(主要还是太懒),Node 节点直接包含了碰撞的接口:
onCollisionBegin(other, tag) {
// Logger.print("onCollisionBegin");
}
onCollision(other, tag) {
// Logger.print("onCollision");
}
onCollisionEnd(other, tag) {
// Logger.print("onCollisionEnd");
}
而要触发碰撞检测,需要通过 PhysicsManager 进行注册,例如在示例游戏中 player 的注册为:
onEnable() {
super.onEnable();
// 注册本节点为 "PLAYER" 类型
PhysicsManager.instance.addCollider("PLAYER", this);
// 添加碰撞规则(允许 "PLAYER" 类型节点与 "ROCK","REDPOCKET" 类型的节点产生碰撞)
PhysicsManager.instance.addRule("PLAYER", "ROCK");
PhysicsManager.instance.addRule("PLAYER", "REDPOCKET");
}
onDisable() {
super.onDisable();
// disable 时进行反注册
PhysicsManager.instance.removeCollider("PLAYER", this);
PhysicsManager.instance.removeRule("PLAYER", "ROCK");
PhysicsManager.instance.removeRule("PLAYER", "REDPOCKET");
}
游戏逻辑
把前面的垃圾代码撸完,目前我们就有了完成一个简单小游戏的基础要素:
- 节点(Node)
- 精灵图显示(Sprite)
- 场景管理(SceneManager)
- 事件系统(EventManager)
- 碰撞检测(PhysicsManager)
- 用户输入(InputManager)
- UI(Button,Text)
接下来就可以开始利用上面的各个模块开始组建简单的游戏,在本示例中,是一个名为 Doge-Jump 的小游戏,它非常简单,只有两个场景:
MainScene
开始场景,里面只有一个开始按钮,点击时跳转到 PlayScene :
export default class MainScene extends Scene {
constructor() {
super();
// 创建开始按钮
let startBtn = new Button('images/ui/BtnNormal.png', 'images/ui/BtnSelected.png', 128, 64, function(point) {
SceneManager.instance.switchToScene("PlayScene");
});
this.addChild(startBtn);
startBtn.position = new Vector2(gm.designWidth / 2, gm.designHeight / 2);
// 设置按钮文字
let startBtnName = new Text('START', 100, "#000000", "middle");
startBtn.addChild(startBtnName);
startBtnName.position = new Vector2(0, 0);
}
}
PlayScene
游玩场景里面其实只有主角 Player 、障碍 Rock 、奖励 RedPocket 、分数 Score 、重开始对话框 Dialog 几种物体(具体代码不再凑篇幅):
Player:
- 定义了 jump 函数,用于让主角进行跳跃,当玩家点击屏幕时触发跳跃;
- 注册了碰撞检测,当与奖励或者障碍碰撞时会回调处理相应逻辑
- 监听了玩家死亡事件,处理停止动画等逻辑
Rock / RedPocket:
都拥有一个对应 Creator 来进行管理,按照一定的时间规律进行创建(其实就是随机 - _ -|| )Score:
监听得分事件,显示对应分数Dialog:
当玩家死亡时弹出,提示是否重新开始
游戏非常简单,开始游戏,点击屏幕控制 Doge 跳跃,不断得分就可以了~
总结
自此,拖了这么久终于把这篇文章的牙膏挤完了(拖了大半年),虽然做的东西其实并没有什么实际意义,但是接触到一些新东西,心血来潮去研究一下还是非常有意思的(打发了春节的无聊时光),但愿以后加班掉发之余还能有时间写写好玩的代码,以及希望有能治好拖延症的药。。。