微信小游戏

原文:https://segmentfault.com/a/1190000012646888 觉得分析的不错 收藏一波! 1.前言 前天一个跳一跳小游戏刷遍了朋友圈,也代表了微信小程序拥有了搭载游戏的功能(早该往这方面发展了,这才是应该有的形态嘛)。作为一个前端er,我的大刀早已经饥渴难耐了,赶紧去下一波最新的微信官方开发工具,体验一波小游戏要如何开发。
微信小游戏
文章图片
微信小游戏
文章图片
微信小游戏
文章图片

我们欣喜地看到可以直接点击小游戏体验一下,而且官方也有一个示例源代码,是一个简易版的飞机大战的源码,直接点开模拟器就可以看效果。
微信小游戏
文章图片

2.源码分析 (还是原汁原味的打飞机游戏呀!)通过阅读这个源代码我们便可以知道如何进行小游戏的开发了。废话少说直接进入主题,先来分析一波源码的整体结构。

路径 内容
audio 音频文件目录
images 图片文件目录
js 主要源代码目录
game.js 游戏主入口
game.json 游戏的配置文件
下面是官方示例中的js文件具体的作用
./js ├── base// 定义游戏开发基础类 │├── animatoin.js// 帧动画的简易实现 │├── pool.js// 对象池的简易实现 │└── sprite.js// 游戏基本元素精灵类 ├── libs │├── symbol.js// ES6 Symbol简易兼容 │└── weapp-adapter.js// 小游戏适配器 ├── npc │└── enemy.js// 敌机类 ├── player │├── bullet.js// 子弹类 │└── index.js// 玩家类 ├── runtime │├── background.js// 背景类 │├── gameinfo.js// 用于展示分数和结算界面 │└── music.js// 全局音效管理器 ├── databus.js// 管控游戏状态 └── main.js// 游戏入口主函数

官方文档中提到, game.jsgame.json是小游戏必须要有的两个文件
下面我会分析我认为主要的文件与结构,不会对每一行代码进行解析,大家有兴趣可以自行阅读官方的源码。每个文件后会跟随我认为重要的几个小点。
game.js
import './js/libs/weapp-adapter' import './js/libs/symbol'import Main from './js/main'new Main()

  1. 小程序启动会调用game.js,在其中导入了小游戏官方提供的适配器,用于注入canvas以及模拟DOM以及BOM(后续会具体说明这个文件),可以在https://mp.weixin.qq.com/debu... 下载源代码,修改适合自己的版本并通过webpack打包自用。当然目前已经足够我们使用。
  2. 导入symbol的polyfill,主要用于模拟ES6类的私有变量。
  3. 导入Main类并实例化Main,于是顺藤摸瓜我们将目光移至Main.js
Main.js
import Playerfrom './player/index' import Enemyfrom './npc/enemy' import BackGround from './runtime/background' import GameInfofrom './runtime/gameinfo' import Musicfrom './runtime/music' import DataBusfrom './databus'let ctx= canvas.getContext('2d') let databus = new DataBus()/** * 游戏主函数 */ export default class Main { constructor() { this.restart() }restart() { databus.reset()canvas.removeEventListener( 'touchstart', this.touchHandler )this.bg= new BackGround(ctx) this.player= new Player(ctx) this.gameinfo = new GameInfo() this.music= new Music()window.requestAnimationFrame( this.loop.bind(this), canvas ) }/** * 随着帧数变化的敌机生成逻辑 * 帧数取模定义成生成的频率 */ enemyGenerate() { if ( databus.frame % 30 === 0 ) { let enemy = databus.pool.getItemByClass('enemy', Enemy) enemy.init(6) databus.enemys.push(enemy) } }// 全局碰撞检测 collisionDetection() { let that = thisdatabus.bullets.forEach((bullet) => { for ( let i = 0, il = databus.enemys.length; i < il; i++ ) { let enemy = databus.enemys[i]if ( !enemy.isPlaying && enemy.isCollideWith(bullet) ) { enemy.playAnimation() that.music.playExplosion()bullet.visible = false databus.score+= 1break } } })for ( let i = 0, il = databus.enemys.length; i < il; i++ ) { let enemy = databus.enemys[i]if ( this.player.isCollideWith(enemy) ) { databus.gameOver = truebreak } } }//游戏结束后的触摸事件处理逻辑 touchEventHandler(e) { e.preventDefault()let x = e.touches[0].clientX let y = e.touches[0].clientYlet area = this.gameinfo.btnAreaif (x >= area.startX && x <= area.endX && y >= area.startY && y <= area.endY) this.restart() }/** * canvas重绘函数 * 每一帧重新绘制所有的需要展示的元素 */ render() { ctx.clearRect(0, 0, canvas.width, canvas.height)this.bg.render(ctx)databus.bullets .concat(databus.enemys) .forEach((item) => { item.drawToCanvas(ctx) })this.player.drawToCanvas(ctx)databus.animations.forEach((ani) => { if ( ani.isPlaying ) { ani.aniRender(ctx) } })this.gameinfo.renderGameScore(ctx, databus.score) }// 游戏逻辑更新主函数 update() { this.bg.update()databus.bullets .concat(databus.enemys) .forEach((item) => { item.update() })this.enemyGenerate()this.collisionDetection() }// 实现游戏帧循环 loop() { databus.frame++this.update() this.render()if ( databus.frame % 20 === 0 ) { this.player.shoot() this.music.playShoot() }// 游戏结束停止帧循环 if ( databus.gameOver ) { this.gameinfo.renderGameOver(ctx, databus.score)this.touchHandler = this.touchEventHandler.bind(this) canvas.addEventListener('touchstart', this.touchHandler)return }window.requestAnimationFrame( this.loop.bind(this), canvas ) } }

  1. 导入了创建游戏需要的我放飞机,敌方飞机,背景,游戏信息,音乐,游戏全局数据类,并获取了canvas的上下文(看到这是不是有一个疑惑,canvas到底是从哪里定义?先带着这个问题最后再说),创建了一个全局数据实例(后面会提到)。
  2. 创建Main的实例自然会调用构造方法,在构造方法中调用restart函数,进行了游戏的初始化并进行循环刷帧(requestAnimationFrame看起来是不是很亲切)。
  3. loop函数中我们可以看到主要调用了update, render方法,并设置了player发射子弹的时间,对游戏是否结束进行判断,最后接着刷帧。
  4. update方法会调用各个场景内对象的update方法来更新他们的位置以及其他信息。
  5. render方法会调用各个场景内对象的render方法来将他们绘制到canvas中。
Main内结构清晰,主要理解整个流程就是调用 requestAnimationFrame来不停地刷帧更新位置信息推动所有对象运动,每个对象在每一帧都有新的位置,连起来就是动画了。分清位置的更新与对象的绘制是关键。
databus.js
import Pool from './base/pool'let instance/** * 全局状态管理器 */ export default class DataBus { constructor() { if ( instance ) return instanceinstance = thisthis.pool = new Pool()this.reset() }reset() { this.frame= 0 this.score= 0 this.bullets= [] this.enemys= [] this.animations = [] this.gameOver= false }/** * 回收敌人,进入对象池 * 此后不进入帧循环 */ removeEnemey(enemy) { let temp = this.enemys.shift()temp.visible = falsethis.pool.recover('enemy', enemy) }/** * 回收子弹,进入对象池 * 此后不进入帧循环 */ removeBullets(bullet) { let temp = this.bullets.shift()temp.visible = falsethis.pool.recover('bullet', bullet) } }

  1. 我们可以看出,databus是一个单例对象,不论在其他代码中new多少次,都是返回的同一个实例,符合我们的期望。
  2. reset定义了所需要的数据源并初始化
  3. 通过一个对象池的概念,控制当前页面对象的数量,避免使用js原有的垃圾处理机制,而是通过对象池来复用已经创建的对象,算是一个性能优化。
  4. frame属性主要是用来刷帧的时候用来控制子弹的发射与敌机的出现时间。
sprite.js
/** * 游戏基础的精灵类 */ export default class Sprite { constructor(imgSrchttps://www.it610.com/article/= '', width=0, height = 0, x = 0, y = 0) { this.img= new Image() this.img.src = https://www.it610.com/article/imgSrcthis.width= width this.height = heightthis.x = x this.y = ythis.visible = true }/** * 将精灵图绘制在canvas上 */ drawToCanvas(ctx) { if ( !this.visible ) returnctx.drawImage( this.img, this.x, this.y, this.width, this.height ) }/** * 简单的碰撞检测定义: * 另一个精灵的中心点处于本精灵所在的矩形内即可 * @param{Sprite} sp: Sptite的实例 */ isCollideWith(sp) { let spX = sp.x + sp.width / 2 let spY = sp.y + sp.height / 2if ( !this.visible || !sp.visible ) return falsereturn !!(spX>= this.x && spX <= this.x + this.width && spY >= this.y && spY <= this.y + this.height) } }

  1. 作为所有场景对象的基类,定义了所有精灵对象基本有的信息(位置,图片,是否可见)
  2. 定义了两种能力,检测碰撞与将自己绘制在canvas上
可以看出画图主要是用的canvas里的drawImage方法,也是我们自行开发小游戏以后会用到的方法。包括background,player等类都会继承自精灵类,并且会添加自己的update方法来暴露更新自己位置信息的接口。enermy还会包装一层爆炸动画的封装,思路大同小异,就不在多赘述了。
3.结论
  1. 我们发现小游戏的开发与我们使用canvas进行h5小游戏的开发并没有什么太大的区别,无论从绘图的api还是事件的api都十分相似,还可以用window对象,这主要归功于官方提供的webapp-adapter.js,该js会注入window对象并提供相应的canvas全局变量,也是文章中提到为什么在main.js里找不到canvas变量在哪里定义的原因了。所以我们可以开开心心地使用canvas来开发小游戏了!!!
  2. 官方还说了一句,可以不引入webapp-adapter.js来开发小游戏,(https://mp.weixin.qq.com/debu...)这是小游戏的api文档(当时找了很久)适配器的源码写得也很清晰,可以一读来了解一些,其中也有很多官方写的TODO的事情,还并不十分完善,如果想要快速移植已有的h5游戏代码使用适配器是很有效的。如果想直接开发小游戏根据api文档直接来开发也是很有效的方法,毕竟引入一层适配器还是会有一定的开销。
tips: 读一读适配器源码也有利于了解如何开发小程序(例如事件绑定之类的操作)
4.结语 小程序终于可以来做小游戏了,感觉还是休闲类的游戏会占主导地位,前端大大可以迎接新的战场啦哈哈哈~~~(接下来会去掉适配器用原生api改写官方demo)
12.30更新
5.无适配器版的官方demo 通过之前的源码分析,我们只能找到使用适配器版本的官方Demo,而找不到一个无适配器版本的官方Demo,于是自己动手丰衣足食,将官方Demo的适配器移除,下面介绍需要进行哪些改动。
  1. 首先对适配器的源码简单阅读后可以发现,适配器做的事情就是模拟了window对象,然后将window对象按devtool和小程序运行的实际环境暴露给全局对象,供我们来使用(devtool里就是window,实际环境中则是GameGlobal)。那么相应我们就该把所有引用到window的地方都进行修改,因为实际运行环境中并没有这个全局对象。下面我主要说明在源代码中使用到window的地方。
    • 我移除了libs/symbol.js,改为直接使用原生支持的symbol来模拟私有变量,其他文件只需删除对该文件的引入即可。
    • 查找各文件使用的window.innerHeightwindow.innerWidth 改为使用 const { screenWidth, screenHeight, devicePixelRatio } = wx.getSystemInfoSync()来获取屏幕宽高与dpr,并在相应地方进行替换。
  2. 音频文件处理
    • 主要是runtime/music.js里与小游戏api的转化,主要是将 new Audio()转化为wx.createInnerAudioContext()方法获取实例和currentTime在原生是一个只读属性,要改为seek方法
      let instanceexport default class Music { constructor() { if ( instance ) return instanceinstance = this// this.bgmAudio = new Audio() this.bgmAudio= wx.createInnerAudioContext() this.bgmAudio.loop = true this.bgmAudio.src= 'https://www.it610.com/article/audio/bgm.mp3'// this.shootAudio= new Audio() this.bgmAudio= wx.createInnerAudioContext() this.shootAudio.src = 'https://www.it610.com/article/audio/bullet.mp3'// this.boomAudio= new Audio() this.bgmAudio= wx.createInnerAudioContext() this.boomAudio.src = 'https://www.it610.com/article/audio/boom.mp3'this.playBgm() }playBgm() { this.bgmAudio.play() }playShoot() { // this.shootAudio.currentTime = 0 this.boomAudio.seek(0) this.shootAudio.play() }playExplosion() { // this.boomAudio.currentTime = 0 this.boomAudio.seek(0) this.boomAudio.play() } }

  3. 图片文件的处理
    • 与音频文件类似,将new Image()替换为wx.createImage()获取实例即可
  4. canvas对象处理
    • 因为需要全局暴露,所以我们把canvas归于到Databus全局管理中去,使用wx.createCanvas()获取全局canvas对象
      export default class DataBus { constructor() { if ( instance ) return instanceinstance = thisthis.pool = new Pool() this.canvas = wx.createCanvas() this.reset() } }

  5. 事件机制
    • canvas对象没有addEventListener之类的方法,同理BOM和DOM对象都没有,所以需要用微信的api来处理事件,demo里则是换为wx.onTouchStart() wx.onTouchMove() wx.onTouchEnd()替换先有的方法。(注意main.js里也有需要替换的,原理一样,不赘述了)
      // player/index.js initEvent() { wx.onTouchStart(((e) => { let x = e.touches[0].clientX let y = e.touches[0].clientY// if (this.checkIsFingerOnAir(x, y)) { this.touched = truethis.setAirPosAcrossFingerPosZ(x, y) }}).bind(this)) wx.onTouchMove(((e) => {let x = e.touches[0].clientX let y = e.touches[0].clientYif (this.touched) this.setAirPosAcrossFingerPosZ(x, y)}).bind(this))wx.onTouchEnd(((e) => { this.touched = false }).bind(this)) }

  6. requestAnimationFrame方法
    • 去掉前面的window就可以了,全局对象里已经支持,setInterval一样
至此我们已经完成了移除适配器,可以在一个极简的条件下开发我们的小游戏了!!
【微信小游戏】官方文档:https://mp.weixin.qq.com/debug/wxagame/dev/index.html?t=20171228

    推荐阅读