从零开始制作微信小游戏-弹一弾,纯原生Canvas与物理引擎Matter.js应用

前言

H5游戏一直以来,以跨平台,低体验著称,其很大原因在于早期技术方案的不成熟和受限于H5游戏编码水平。但现今,Canvas和WebGL的渲染性能已经很好了,合理编码的情况下,体验与原生应用游戏并无区别

由微信小程序衍生且独立而出的【微信小游戏】便是瞄准了Web游戏渲染,代表着这是未来游戏制作一个很大方向上的趋势。微信小游戏运行环境移除了BOM和DOM,这是一个很有意思的方案,因为这意味着游戏开发者必须用纯canvas绘制游戏内容,这对于游戏性能的提升是巨大的

同时,为了保留对游戏引擎的支持和减少现行大量H5游戏的迁移工作,微信小游戏官方提供了weapp-adapter适配器,通过微信小游戏官方的适配器或自行开发编写的适配器,可以兼容很多的BOM或DOM的API

因为微信小游戏平台才刚刚推出,目前网络上大量存在的,包括github上开源的微信小游戏其实都是微信小程序的网页版本,和传统页游没区别,受限于BOM和DOM,性能和体验上都并不好。本文的主旨在于从零开始,以纯Canvas的开发方式,制作一个微信小游戏上非常流行和好玩的游戏——【弹一弾】

演示

elastic-demo.gif

H5模式演示版本:https://cheneyweb.github.io/wxgame-elastic/dist/index.html

H5模式二维码,手机扫码体验(微信扫码,浏览器扫码等都可以)
elastic-qrcode.png

微信小游戏模式演示版本:需要打开微信开发者工具导入工程目录

思路

【弹一弾】游戏的核心在于对物理弹动的真实模拟和大量物体元素的碰撞交互,是一个非常有挑战的游戏制作

任何的游戏开发开发离不开游戏引擎,因为纯原生的编码制作游戏效率是非常低下的,而且难以维护,所以工欲善其,必先利其器,在开发【弹一弾】的同时,我们还需要先制作一个精简高效的canvas游戏引擎(称之为游戏引擎是不合适的,因为我们不可能在短时间内完成一个游戏引擎的开发,这里只是为了类比了游戏引擎的少部分功能)

任何的游戏其本质一定是包含着一个或多个循环,这才会有了我们所见的动画效果,下面先列举【弹一弾】的开发思路

  1. 统一的资源定义(包括图片,音效,音乐)等资源
  2. 统一的资源加载(初始资源在内存中的载入)
  3. 统一的状态管理(全局变量数据的维护,这里说个题外话,我本人非常不喜欢状态管理之类的的全局变量方案,但是在游戏开发中,这是必须且不得不引入的,因为游戏编程对于状态变更的需求非常大,合理的使用全局变量能大大提高编码效率)
  4. 统一的资源渲染,绘制呈现
  5. 全局物理引擎,负责模拟弹性碰撞实现,实现游戏核心逻辑
  6. 面向对象的开发思路,以物体元素作为游戏内容单位,制定每个物体元素的行为和逻辑

以上的1-4点就是我们需要制作的简单高效的精简版“游戏引擎”,有了1-4的基础铺垫后,通过5的引入和6的自定义展开,我们就可以完成【弹一弾】的制作

这里需要补充说明的是第5点,物理引擎,为了开发【弹一弾】我寻找对比了多款JS物理引擎。目前的现状是大部分JS物理引擎都已经处于停止开发维护的状态,多款知名的JS物理引擎在github上已经多年没更新。或许是因为物理引擎的门槛较高和H5游戏早年的发展不顺利导致。但对游戏来说,物理引擎是非常核心且重要的一环,很多PC和Mobile上的游戏大作,之所以体验良好,就是因为有强大的物理引擎作为背后支撑,但是这些大作的物理引擎很多都是商业版本,价格高昂且不开源

不过所幸的是,有一款JS物理引擎很突出,性能和功能很强大,且目前有着持续性的维护,它就是Matter.js。这款物理引擎几乎是我制作弹一弾的唯一选择,我个人测试下来问题并不多,有部分问题可以通过了对源码的一些修改解决。需要特别说明的是Matter物理引擎也是知名游戏引擎Laya和Egret的开发常选

实践

整个开发流程会分七步走,需要注意的是,因为文章篇幅所限,不可能展示所有代码,但所有核心流程都会有介绍说明,在文末我会附上项目的github地址,提供大家参考

1、开发环境准备

相比传统游戏开发,H5游戏的开发环境十分简单轻巧,而且我们不采用商业游戏引擎,而是纯原生开发,所有我们只需要一个关键工具:

微信开发者工具


微信开发者工具.png

2、开发精简版的游戏引擎

一个超级无敌精简版的游戏引擎需要什么功能,那就是把游戏画面渲染绘制出来。 所以理论上我们只需要一个“画笔类”就够了,这支画笔能够绘制出我们想要的内容。当然,除了画笔之外,我们也还需要一些其他的关键组件
我们命名一个文件夹——"base",然后在这个文件夹内放置我们所有需要的游戏基础类

    ├── base  精简版游戏引擎
    │   ├── Body.js  物理物体元素基类
    │   ├── DataStore.js  全局状态管理类
    │   ├── Resource.js  统一资源定义类
    │   ├── ResourceLoader.js  统一资源加载类
    │   ├── Sprite.js  普通物体渲染画笔类
    │   └── matter.js  物理引擎

Resource.js
这是统一资源管理类,非常简单,因为整个游戏只需要两张图片和两个音效

export const Resources = [
    ['background', 'res/background.png'],
    ['startButton', 'res/startbutton.png'],
    ['bgm', 'res/xuemaojiao.mp3'],
    ['launch', 'res/launch.mp3']
]

ResourceLoader.js
这是统一资源加载类,同样简单,我们只需要在资源加载后回调即可,因为微信小游戏的图片和音效资源的加载需要其官方API,这里和H5原生标准稍有不同

//资源文件加载器,确保在图片资源加载完成后才渲染
import { Resources } from './Resource.js'

export class ResourceLoader {
  constructor() {
    this.imageCount = 0
    this.audioCount = 0
    //导入资源
    this.map = new Map(Resources)
    for (let [key, src] of this.map) {
      let res = null
      if (src.split('.')[1] == 'png' || src.split('.')[1] == 'jpg') {
        this.imageCount++
        // H5创建image的API
        res = new Image()
        // 微信创建image的API
        // res = wx.createImage()
        res.src = src
      } else {
        this.audioCount++
        // H5创建audio的API
        res = new Audio()
        // 微信创建audio的API
        // res = wx.createInnerAudioContext()
        res.src = src
      }
      this.map.set(key, res)
    }
  }

  // 加载完成回调
  onload(cb) {
    let loadCount = 0
    for (let res of this.map.values()) {
      // 使this指向当前的ResourceLoader
      res.onload = () => {
        loadCount++
        if (loadCount >= this.imageCount) {
          cb(this.map)
        }
      }
    }
  }
}

Sprite.js
这是普通物体渲染画笔类,目前我们只需要封装底层的canvas的图片绘制即可

import { DataStore } from './DataStore.js'
export class Sprite {
  constructor(ctx, img, x = 0, y = 0, w = 0, h = 0, srcX = 0, srcY = 0, srcW = 0, srcH = 0, ) {
    this.ctx = ctx
    this.img = img
    this.srcX = srcX
    this.srcY = srcY
    this.srcW = srcW
    this.srcH = srcH
    this.x = x
    this.y = y
    this.w = w
    this.h = h
  }

  /**
   * 绘制图片
   * img 传入Image对象
   * srcX 要剪裁的起始X坐标
   * srcY 要剪裁的起始Y坐标
   * srcW 剪裁的宽度
   * srcH 剪裁的高度
   * x 放置的x坐标
   * y 放置的y坐标
   * w 要使用的宽度
   * h 要使用的高度
   */
  draw(img = this.img,
    x = this.x, y = this.y, w = this.w, h = this.h,
    srcX = this.srcX, srcY = this.srcY, srcW = this.srcW, srcH = this.srcH) {
    this.ctx.drawImage(img, srcX, srcY, srcW, srcH, x, y, w, h)
  }

  static getImage(key) {
    return DataStore.getInstance().res.get(key)
  }
}

Body.js
这是物理物体元素基类,目前只需要实现引入物理引擎实例即可

// 物体基类
export class Body {
  constructor(physics) {
    this.physics = physics
  }
}

3、编码游戏主逻辑

App.js
这是游戏的入口,也是整个游戏应用类,只需要canvas实例,以及拓展物理引擎实例作为入参,即可实例化该游戏应用

import { ResourceLoader } from './src/base/ResourceLoader.js'
import { DataStore } from './src/base/DataStore.js'
import { Director } from './src/Director.js'

/**
 * 游戏入口
 */
export class App {
  constructor(canvas, options) {
    this.canvas = canvas                                             // 画布
    this.physics = { ...options, ctx: this.canvas.getContext('2d') } // 物理引擎
    this.director = new Director(this.physics)                       // 导演
    this.dataStore = DataStore.getInstance()
    // 资源加载
    new ResourceLoader().onload(res => {
      // 持久化资源
      this.dataStore.res = res
      // 加载精灵
      this.director.spriteLoad(res)
      // 运行游戏
      this.run()
    })
  }

  /**
   * 运行游戏
   */
  run() {
    // 注册事件
    this.registerEvent()
    // 物理渲染
    this.director.physicsDirect()
    // 精灵渲染
    this.director.spriteDirect()
    // 音乐播放
    this.dataStore.res.get('bgm').autoplay = true
  }

  /**
   * 重新加载游戏
   */
  reload() {
    // 物理渲染
    this.director.physicsDirect(true)
    // 精灵渲染
    this.director.spriteDirect(true)
  }

  /**
   * 注册事件
   */
  registerEvent() {
    // 移动设备触摸事件,使用=>使this指向Main类
    this.canvas.addEventListener('touchstart', e => {
      // 屏蔽事件冒泡
      e.preventDefault()
      // 如果游戏是结束状态,则重新开始
      if (this.dataStore.isGameOver) {
        // 重新初始化
        this.dataStore.isGameOver = false
        this.reload()
      }
    })
    // PC设备点击事件
    this.canvas.addEventListener('mousedown', e => {
      // 屏蔽事件冒泡
      e.preventDefault()
      // 如果游戏是结束状态,则重新开始
      if (this.dataStore.isGameOver) {
        // 重新初始化
        this.dataStore.isGameOver = false
        this.reload()
      }
    })
  }
}

Director.js
这是游戏导演类,负责游戏主逻辑调度调配,以及游戏画面渲染工作

// 精灵对象
import { BackGround } from './sprite/BackGround.js'
import { StartButton } from './sprite/StartButton.js'
import { Score } from './sprite/Score.js'
// 物理引擎绘制对象
import { Block } from './body/Block.js'
import { Border } from './body/Border.js'
import { Bridge } from './body/Bridge.js'
import { Aim } from './body/Aim.js'
// 数据管理
import { DataStore } from './base/DataStore.js'

/**
 * 导演类,控制游戏的逻辑
 */
export class Director {
  constructor(physics) {
    this.physics = physics
    this.dataStore = DataStore.getInstance()
  }
  // 加载精灵对象
  spriteLoad() {
    this.sprite = new Map()
    this.sprite['score'] = new Score(this.physics)
    this.sprite['startButton'] = new StartButton(this.physics)
    this.sprite['background'] = new BackGround(this.physics)
  }
  // 逐帧绘制
  spriteDirect(isReload) {
    if(isReload){
      this.dataStore.scoreCount = 0
    }
    // 绘制前先判断是否碰撞
    // this.check()
    // 游戏未结束
    if (!this.dataStore.isGameOver) {
      // 绘制游戏内容
      this.sprite['score'].draw()
      // this.sprite['background'].draw()
      // 自适应浏览器的帧率,提高性能
      this.animationHandle = requestAnimationFrame(() => this.spriteDirect())
    }
    //  游戏结束
    else {
      // 停止物理引擎
      this.physics.Matter.Engine.clear(this.physics.engine)
      this.physics.Matter.World.clear(this.physics.engine.world)
      this.physics.Matter.Render.stop(this.physics.render)
      // 停止绘制
      cancelAnimationFrame(this.animationHandle)
      // 结束界面
      this.sprite['score'].draw()
      this.sprite['startButton'].draw()
    }
  }
  // 物理绘制
  physicsDirect(isReload) {
    this.physics.Matter.Render.run(this.physics.render)
    if (!isReload) {
      new Aim(this.physics).draw().event()
      // new Bridge(this.physics).draw()
    }
    new Block(this.physics).draw().event().upMove()
    new Border(this.physics).draw()
  }
}

4、渲染基础物体元素

BackGround.js
从此处开始,就已经使用搭建好的游戏框架,开始正式设计和绘制游戏内容,在这里以最简单的背景类举例,这个基础物体非常简单,且只做了一件事情,那就是绘制游戏背景。剩余的基础物体还有计分器和游戏开始按钮,限于篇幅不做展开,文末会有本项目的github开源项目地址

import { Sprite } from '../base/Sprite.js'
/**
 * 背景类
 */
export class BackGround extends Sprite {
  constructor(physics) {
    const image = Sprite.getImage('background')
    super(
      physics.ctx, image,
      (physics.canvas.width - image.width) / 2,
      (physics.canvas.height - image.height) / 2.5,
      image.width, image.height,
      0,
      0,
      image.width, image.height
    )
  }
}

5、引入物理引擎

为了让matter.js这个物理引擎能够适合游戏的开发需求,我们需要对其进行适当的修改,让其增加能够渲染文字等功能,所以我们选择了matter.js的未压缩版本
在matter.js的Render.bodies方法中,跟着c.globalAlpha = 1;之后,增加拓展代码

c.globalAlpha = 1;
// 增加自定义渲染TEXT
if (part.render.text) {
    // 30px is default font size
    var fontsize = 30;
    // arial is default font family
    var fontfamily = part.render.text.family || "Arial";
    // white text color by default
    var color = part.render.text.color || "#FFFFFF";
    // text maxWidth
    var maxWidth = part.render.text.maxWidth

    if (part.render.text.size)
        fontsize = part.render.text.size;
    else if (part.circleRadius)
        fontsize = part.circleRadius / 2;

    var content = "";
    if (typeof part.render.text == "string")
        content = part.render.text;
    else if (part.render.text.content)
        content = part.render.text.content;

    c.textBaseline = "middle";
    c.textAlign = "center";
    c.fillStyle = color;
    c.font = fontsize + 'px ' + fontfamily;
    if (part.bounds) {
        maxWidth = part.bounds.max.x - part.bounds.min.x;
    }
    c.fillText(content, part.position.x, part.position.y, maxWidth);
}

game.js
对Matter物理引擎做一些调整之后,我们就可以在微信小游戏的入口文件中引入,并初始化【弹一弾】游戏实例

// require('./src/base/weapp-adapter.js')
const Matter = require('./src/base/matter.js')
import { App } from './App.js'

// 同时兼容H5模式和微信小游戏模式
const canvas = typeof wx == 'undefined' ? document.getElementById('app') : wx.createCanvas()
// H5网页游戏模式
if (typeof wx == 'undefined') {
  canvas.width = 375
  canvas.height = 667
}
// 微信小游戏模式
else {
  window.Image = () => wx.createImage()
  window.Audio = () => wx.createInnerAudioContext()
}
// 初始化物理引擎
const engine = Matter.Engine.create({
  enableSleeping: true
})
const render = Matter.Render.create({
  canvas: canvas,
  engine: engine,
  options: {
    width: canvas.width,
    height: canvas.height,
    background: './res/background.png', // transparent
    wireframes: false,
    showAngleIndicator: false
  }
})
Matter.Engine.run(engine)
// Matter.Render.run(render)

// 初始化游戏
const physics = { Matter, engine, canvas, render }
new App(canvas, physics)

6、渲染物理物体元素

Border.js
当基础物体渲染工作和物理引擎引入工作完成后,就可以开始利用物理引擎绘制我们需要的物理物体元素,在【弹一弾】游戏中,总共有三种物理物体,分别是墙体,弹球,方块

在这以最简单的墙体为例,其余比较复杂的弹球和方块,代码比较长,在此限于篇幅不展开,文末会有本项目开源的github地址,可以前往进一步了解

// 边界
import { Body } from '../base/Body.js'

export class Border extends Body {
  constructor(physics) {
    super(physics)
  }

  draw() {
    const physics = this.physics
    let bottomHeight = 10
    let leftWidth = 10
    const borderBottom = physics.Matter.Bodies.rectangle(
      physics.canvas.width / 2, physics.canvas.height - bottomHeight / 2,
      physics.canvas.width - leftWidth * 2, bottomHeight, {
        isStatic: true,
        render: {
          visible: true
        }
      })
    const borderLeft = physics.Matter.Bodies.rectangle(
      leftWidth / 2, physics.canvas.height / 2,
      leftWidth, physics.canvas.height, {
        isStatic: true,
        render: {
          visible: true
        }
      })
    const borderRight = physics.Matter.Bodies.rectangle(
      physics.canvas.width - leftWidth / 2, physics.canvas.height / 2,
      leftWidth, physics.canvas.height, {
        isStatic: true,
        render: {
          visible: true
        }
      })
    physics.Matter.World.add(physics.engine.world, [borderBottom, borderLeft, borderRight])
  }

}

7、游戏完成,项目总览

到此为止,整个【弹一弾】微信小游戏的制作就完成了,其实回首梳理整个流程,还算是流畅,也不复杂,但是很多时候万事开头难,在一开始我的确遇到了很多很多的问题,包括物理引擎的引入,游戏逻辑的合理安排,算是一些挑战,所幸这些问题很多都解决了,也就有了此文

当然还有一些问题我至今还没有完美解决,例如当球速过快引起的“穿墙”问题,这其实是Matter.js物理引擎的问题,在github上有关这一问题的讨论,作者还建立了CCD算法分支尝试解决,但是遗憾的是,截止文本完成时间,这一问题仍然没有在Matter.js上得到解决,如果读者们有解决思路的,也可以联系我,不胜感激

另外,【弹一弾】整个游戏我目前为止完成了核心交互逻辑,但是比起微信上的弹一弾游戏,很多细节都还没有做,例如美术风格的完善和弹球的回收,以及多样的方块和道具,这些以后如果有时间,我会进一步完善

我个人非常追求极简和拓展,以下是【弹一弾】的工程目录结构

├── App.js  弹一弾游戏入口
├── game.js  微信小游戏入口
├── game.json
├── project.config.json
├── res  资源集合
│   ├── background.png
│   ├── launch.mp3
│   ├── startbutton.png
│   └── xuemaojiao.mp3
└── src
    ├── Director.js  导演
    ├── base  精简版游戏引擎
    │   ├── Body.js  物理物体元素基类
    │   ├── DataStore.js  全局状态管理类
    │   ├── Resource.js  统一资源定义类
    │   ├── ResourceLoader.js  统一资源加载类
    │   ├── Sprite.js  普通物体渲染画笔类
    │   └── matter.js  物理引擎
    ├── body  物理物体元素
    │   ├── Aim.js  准星瞄准
    │   ├── Block.js  障碍方块
    │   ├── Border.js  边界墙体
    └── sprite  普通物体元素
        ├── BackGround.js  游戏背景
        ├── Score.js  游戏分数
        └── StartButton.js  开始按钮

后记

【弹一弾】的开发我选用了纯canvas的方案,一方面适合微信小游戏平台,一方面也能兼容H5网页,同时性能良好,整个游戏的大小不超过1MB,可说是非常迷你,但是麻雀虽小五脏俱全
另外,因为没有采用游戏引擎,而是搭建制作了一个精简迷你的游戏开发框架,所以我也没有采用微信官方的适配器weapp-adapter,一来可以节省53KB,二来可以提升代码执行效率,让快更快
当然,全文中我所描述制作的精简版游戏引擎,其实比起目前主流的商业游戏引擎,只是冰山一角,目的只是为了让更多初入门的玩家能对游戏引擎有个初步的概念。真正的商业游戏引擎例如Laya和Egret,功能十分强大,我后续也会出一篇文章,采用这类商业游戏引擎将【弹一弾】重做一遍
从现今微信小游戏的发展上我们可以展望,未来H5之类的纯Web游戏很可能会占据游戏市场很大份额,使得游戏开发也彻底走向真正的跨平台,热更新,高性能

感谢你的阅读,希望本文能够给你带来帮助:)

作者:CheneyXu
Github:wxgame-elastic
关于:XServer官网

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容