從零開始製做微信小遊戲-彈一弾,純原生Canvas與物理引擎Matter.js應用

前言

H5遊戲一直以來,以跨平臺,低體驗著稱,其很大緣由在於早期技術方案的不成熟和受限於H5遊戲編碼水平。但現今,Canvas和WebGL的渲染性能已經很好了,合理編碼的狀況下,體驗與原生應用遊戲並沒有區別html

由微信小程序衍生且獨立而出的 【微信小遊戲】即是瞄準了Web遊戲渲染,表明着這是將來遊戲製做一個很大方向上的趨勢。微信小遊戲運行環境移除了BOM和DOM,這是一個頗有意思的方案,由於這意味着遊戲開發者必須用純canvas繪製遊戲內容,這對於遊戲性能的提高是巨大的git

同時,爲了保留對遊戲引擎的支持和減小現行大量H5遊戲的遷移工做,微信小遊戲官方提供了weapp-adapter適配器,經過微信小遊戲官方的適配器或自行開發編寫的適配器,能夠兼容不少的BOM或DOM的APIgithub

由於微信小遊戲平臺纔剛剛推出,目前網絡上大量存在的,包括github上開源的微信小遊戲其實都是微信小程序的網頁版本,和傳統頁遊沒區別,受限於BOM和DOM,性能和體驗上都並很差。本文的主旨在於從零開始,以純Canvas的開發方式,製做一個微信小遊戲上很是流行和好玩的遊戲——【彈一弾】web

演示

elastic-demo.gif

H5模式演示版本:cheneyweb.github.io/wxgame-elas…算法

H5模式二維碼,手機掃碼體驗(微信掃碼,瀏覽器掃碼等均可以)編程

H5模式二維碼.png

微信小遊戲模式演示版本:須要打開微信開發者工具導入工程目錄json

思路

【彈一弾】遊戲的核心在於對物理彈動的真實模擬和大量物體元素的碰撞交互,是一個很是有挑戰的遊戲製做canvas

任何的遊戲開發開發離不開遊戲引擎,由於純原生的編碼製做遊戲效率是很是低下的,並且難以維護,因此工欲善其,必先利其器,在開發【彈一弾】的同時,咱們還須要先製做一個精簡高效的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地址,提供你們參考

一、開發環境準備

相比傳統遊戲開發,H5遊戲的開發環境十分簡單輕巧,並且咱們不採用商業遊戲引擎,而是純原生開發,全部咱們只須要一個關鍵工具:

微信開發者工具

微信開發者工具.png

二、開發精簡版的遊戲引擎

一個超級無敵精簡版的遊戲引擎須要什麼功能,那就是把遊戲畫面渲染繪製出來。 因此理論上咱們只須要一個「畫筆類」就夠了,這支畫筆可以繪製出咱們想要的內容。固然,除了畫筆以外,咱們也還須要一些其餘的關鍵組件 咱們命名一個文件夾——"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
  }
}
複製代碼

三、編碼遊戲主邏輯

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()
  }
}
複製代碼

四、渲染基礎物體元素

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
    )
  }
}
複製代碼

五、引入物理引擎

爲了讓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)
複製代碼

六、渲染物理物體元素

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])
  }

}
複製代碼

七、遊戲完成,項目總覽

到此爲止,整個【彈一弾】微信小遊戲的製做就完成了,其實回首梳理整個流程,還算是流暢,也不復雜,可是不少時候萬事開頭難,在一開始個人確遇到了不少不少的問題,包括物理引擎的引入,遊戲邏輯的合理安排,算是一些挑戰,所幸這些問題不少都解決了,也就有了此文

固然還有一些問題我至今尚未完美解決,例如當球速過快引發的「穿牆」問題,這實際上是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官網

相關文章
相關標籤/搜索