前天一个跳一跳
小游戏刷遍了朋友圈,也代表了微信小程序拥有了搭载游戏的功能(早该往这方面发展了,这才是应该有的形态嘛)。作为一个前端er,我的大刀早已经饥渴难耐了,赶紧去下一波最新的微信官方开发工具,体验一波小游戏要如何开发。
我们欣喜地看到可以直接点击小游戏体验一下,而且官方也有一个示例源代码,是一个简易版的飞机大战的源码,直接点开模拟器就可以看效果。
(还是原汁原味的打飞机游戏呀!)通过阅读这个源代码我们便可以知道如何进行小游戏的开发了。废话少说直接进入主题,先来分析一波源码的整体结构。
路径 | 内容 |
---|---|
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.js
和game.json
是小游戏必须要有的两个文件
下面我会分析我认为主要的文件与结构,不会对每一行代码进行解析,大家有兴趣可以自行阅读官方的源码。每个文件后会跟随我认为重要的几个小点。
import './js/libs/weapp-adapter'
import './js/libs/symbol'
import Main from './js/main'
new Main()
game.js
,在其中导入了小游戏官方提供的适配器,用于注入canvas以及模拟DOM以及BOM(后续会具体说明这个文件),可以在https://mp.weixin.qq.com/debu... 下载源代码,修改适合自己的版本并通过webpack打包自用。当然目前已经足够我们使用。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
)
}
}
requestAnimationFrame
看起来是不是很亲切)。
Main内结构清晰,主要理解整个流程就是调用 requestAnimationFrame
来不停地刷帧更新位置信息推动所有对象运动,每个对象在每一帧都有新的位置,连起来就是动画了。分清位置的更新与对象的绘制是关键。
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)
}
}
/**
* 游戏基础的精灵类
*/
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 )
}
}
可以看出画图主要是用的canvas里的drawImage方法,也是我们自行开发小游戏以后会用到的方法。包括background,player等类都会继承自精灵类,并且会添加自己的update方法来暴露更新自己位置信息的接口。enermy还会包装一层爆炸动画的封装,思路大同小异,就不在多赘述了。
webapp-adapter.js
,该js会注入window对象并提供相应的canvas全局变量,也是文章中提到为什么在main.js里找不到canvas变量在哪里定义的原因了。所以我们可以开开心心地使用canvas来开发小游戏了!!!webapp-adapter.js
来开发小游戏,(https://mp.weixin.qq.com/debu...)这是小游戏的api文档(当时找了很久)适配器的源码写得也很清晰,可以一读来了解一些,其中也有很多官方写的TODO的事情,还并不十分完善,如果想要快速移植已有的h5游戏代码使用适配器是很有效的。如果想直接开发小游戏根据api文档直接来开发也是很有效的方法,毕竟引入一层适配器还是会有一定的开销。tips: 读一读适配器源码也有利于了解如何开发小程序(例如事件绑定之类的操作)
小程序终于可以来做小游戏了,感觉还是休闲类的游戏会占主导地位,前端大大可以迎接新的战场啦哈哈哈~~~(接下来会去掉适配器用原生api改写官方demo)
12.30更新
通过之前的源码分析,我们只能找到使用适配器版本的官方Demo,而找不到一个无适配器版本的官方Demo,于是自己动手丰衣足食,将官方Demo的适配器移除,下面介绍需要进行哪些改动。
首先对适配器的源码简单阅读后可以发现,适配器做的事情就是模拟了window对象,然后将window对象按devtool和小程序运行的实际环境暴露给全局对象,供我们来使用(devtool里就是window,实际环境中则是GameGlobal)。那么相应我们就该把所有引用到window的地方都进行修改,因为实际运行环境中并没有这个全局对象。下面我主要说明在源代码中使用到window的地方。
libs/symbol.js
,改为直接使用原生支持的symbol来模拟私有变量,其他文件只需删除对该文件的引入即可。window.innerHeight
与window.innerWidth
改为使用 const { screenWidth, screenHeight, devicePixelRatio } = wx.getSystemInfoSync()
来获取屏幕宽高与dpr,并在相应地方进行替换。音频文件处理
主要是runtime/music.js
里与小游戏api的转化,主要是将 new Audio()
转化为wx.createInnerAudioContext()
方法获取实例和currentTime
在原生是一个只读属性,要改为seek
方法
let instance
export 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()
}
}
图片文件的处理
new Image()
替换为wx.createImage()
获取实例即可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()
}
}
事件机制
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 = 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))
}
requestAnimationFrame
方法
window
就可以了,全局对象里已经支持,setInterval
一样至此我们已经完成了移除适配器,可以在一个极简的条件下开发我们的小游戏了!!
官方文档:https://mp.weixin.qq.com/debug/wxagame/dev/index.html?t=20171228