微信小游戏初体验

前言

上周【跳一跳】小游戏刷遍了朋友圈,也代表了微信小程序拥有了搭载游戏的功能(早该往这方面发展了,这才是应该有的形态嘛)。作为一个前端er,我的大刀早已经饥渴难耐了,赶紧去下一波最新的微信官方开发工具,体验一波小游戏要如何开发。

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

源码分析

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

下面是官方示例中的js文件具体的作用:

官方文档中提到,game.js和game.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... 下载源代码,修改2、适合自己的版本并通过webpack打包自用。当然目前已经足够我们使用。

3、导入symbol的polyfill,主要用于模拟ES6类的私有变量。

4、导入Main类并实例化Main,于是顺藤摸瓜我们将目光移至Main.js

Main.js

import Player     from './player/index'import Enemy      from './npc/enemy'import BackGround from './runtime/background'import GameInfo   from './runtime/gameinfo'import Music      from './runtime/music'import DataBus    from './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 = this

    databus.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  += 1

          break
        }
      }
    })    for ( let i = 0, il = databus.enemys.length; i < il;i++ ) {      let enemy = databus.enemys[i]      if ( this.player.isCollideWith(enemy) ) {
        databus.gameOver = true

        break
      }
    }
  }  //游戏结束后的触摸事件处理逻辑
  touchEventHandler(e) {
    e.preventDefault()    let x = e.touches[0].clientX    let y = e.touches[0].clientY    let area = this.gameinfo.btnArea    if (   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 instance

    instance = this

    this.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 = false

    this.pool.recover('enemy', enemy)
  }  /**
   * 回收子弹,进入对象池
   * 此后不进入帧循环
   */
  removeBullets(bullet) {    let temp = this.bullets.shift()

    temp.visible = false

    this.pool.recover('bullet', bullet)
  }
}

1、我们可以看出,databus是一个单例对象,不论在其他代码中new多少次,都是返回的同一个实例,符合我们的期望。

2、reset定义了所需要的数据源并初始化

3、通过一个对象池的概念,控制当前页面对象的数量,避免使用js原有的垃圾处理机制,而是通过对象池来复用已经创建的对象,算是一个性能优化。

4、frame属性主要是用来刷帧的时候用来控制子弹的发射与敌机的出现时间。

sprite.js

/**
 * 游戏基础的精灵类
 */export default class Sprite {  constructor(imgSrc = '', width=  0, height = 0, x = 0, y = 0) {    this.img     = new Image()    this.img.src = imgSrc    this.width  = width    this.height = height    this.x = x    this.y = y    this.visible = true
  }  /**
   * 将精灵图绘制在canvas上
   */
  drawToCanvas(ctx) {    if ( !this.visible )      return

    ctx.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 / 2

    if ( !this.visible || !sp.visible )      return false

    return !!(   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还会包装一层爆炸动画的封装,思路大同小异,就不在多赘述了。

结论

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: 读一读适配器源码也有利于了解如何开发小程序(例如事件绑定之类的操作)

无适配器版的官方demo

通过之前的源码分析,我们只能找到使用适配器版本的官方Demo,而找不到一个无适配器版本的官方Demo,于是自己动手丰衣足食,将官方Demo的适配器移除,下面介绍需要进行哪些改动。

1、首先对适配器的源码简单阅读后可以发现,适配器做的事情就是模拟了window对象,然后将window对象按devtool和小程序运行的实际环境暴露给全局对象,供我们来使用(devtool里就是window,实际环境中则是GameGlobal)。那么相应我们就该把所有引用到window的地方都进行修改,因为实际运行环境中并没有这个全局对象。下面我主要说明在源代码中使用到window的地方。

  • 我移除了libs/symbol.js,改为直接使用原生支持的symbol来模拟私有变量,其他文件只需删除对该文件的引入即可。
  • 查找各文件使用的window.innerHeight与window.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 instance

    instance = this

    // this.bgmAudio = new Audio()
    this.bgmAudio      = wx.createInnerAudioContext()    this.bgmAudio.loop = true
    this.bgmAudio.src  = 'audio/bgm.mp3'

    // this.shootAudio     = new Audio()
    this.bgmAudio      = wx.createInnerAudioContext()    this.shootAudio.src = 'audio/bullet.mp3'

    // this.boomAudio     = new Audio()
    this.bgmAudio      = wx.createInnerAudioContext()    this.boomAudio.src = '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 instance

    instance = this

    this.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.jsinitEvent() {
wx.onTouchStart(((e) => {  let x = e.touches[0].clientX  let y = e.touches[0].clientY  //
  if (this.checkIsFingerOnAir(x, y)) {    this.touched = true

    this.setAirPosAcrossFingerPosZ(x, y)
  }

}).bind(this))
wx.onTouchMove(((e) => {  let x = e.touches[0].clientX  let y = e.touches[0].clientY  if (this.touched)    this.setAirPosAcrossFingerPosZ(x, y)

}).bind(this))

wx.onTouchEnd(((e) => {  this.touched = false}).bind(this))
}

6、requestAnimationFrame方法

去掉前面的window就可以了,全局对象里已经支持,setInterval一样。

至此我们已经完成了移除适配器,可以在一个极简的条件下开发我们的小游戏了!!

感谢本文作者kedaya的精彩分享本文可被转发或分享,但必须保留完整图文信息和出处,作者保留追究一切法律责任的权利和手段~

原文发布于微信公众号 - 极乐技术社区(wxapp-union)

原文发表时间:2018-01-02

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏编舟记

Clojure集合管道函数练习

TDD讨论组里的申导最近在B站直播了Martin Fowler的经典文章Refactoring with Loops and Collection Pipeli...

1492
来自专栏机器学习算法与Python学习

精选26个Python实用技巧,想秀技能先Get这份技术列表!

Python 虽然是脚本语言,但是因为其易学,迅速成为科学家的工具,从而积累了大量的工具库、架构,人工智能涉及大量的数据科学,用 Python 是很自然的事。磨...

1412
来自专栏AI科技大本营的专栏

精选26个Python实用技巧,想秀技能先Get这份技术列表!

【导读】Python 虽然是脚本语言,但是因为其易学,迅速成为科学家的工具,从而积累了大量的工具库、架构,人工智能涉及大量的数据科学,用 Python 是很自然...

1475
来自专栏编程

C语言嵌入式系统编程修炼之键盘操作

这是我13年前创作和发表在互联网上的文章,这么多年过去了,这篇文章仍然在到处传播。现在贴回Linuxer公众号。 全文目录: C语言嵌入式系统编程修炼之道——背...

2609
来自专栏java一日一条

Jsoup代码解读之四-parser(上)

作为Java世界最好的HTML 解析库,Jsoup的parser实现非常具有代表性。这部分也是Jsoup最复杂的部分,需要一些数据结构、状态机乃至编译器的知识。...

1001
来自专栏Web 开发

知呼前端工程师面试题目

第三题的答案(我的答案有点问题,等弄好了再上传 要想完美,先出主体,通过负margin+双容器)

890
来自专栏Flutter入门到实战

那些年遇到的后台返回的奇葩json数据

然而:错误数据返回null不说,错误信息居然返回一个一个url?就这么一点错误信息,还要我再去请求一次服务器获取这个错误信息吗。。 服务器流量不要钱的吧。。。...

6723
来自专栏王清培的专栏

.NET重构—单元测试的代码重构

阅读目录: 1.开篇介绍 2.单元测试、测试用例代码重复问题(大量使用重复的Mock对象及测试数据) 2.1.单元测试的继承体系(利用超类来减少Mock对象的...

2206
来自专栏木子昭的博客

Javascript是个好东西(广大人民的智慧是无穷的):

图片发自简书App 1,面向对象? 其他编程语言对于面向对象要么支持,要么不支持,而js支持原型链,具体的实现要自己动手,实现的方式也是各种流派(相当于别人家的...

2858
来自专栏程序人生

C语言中的封装 - 答读者问

写C代码的时候,最头疼的事情是哪些信息要暴露给外界,哪些隐藏在模块自身。如果不能处理好封装,那么久而久之,代码就自然演进成互相缠绕的意大利面条。 比如说在一个r...

5036

扫码关注云+社区

领取腾讯云代金券