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

cheneyweb發表於2018-06-27

前言

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

由微信小程式衍生且獨立而出的 【微信小遊戲】便是瞄準了Web遊戲渲染,代表著這是未來遊戲製作一個很大方向上的趨勢。微信小遊戲執行環境移除了BOM和DOM,這是一個很有意思的方案,因為這意味著遊戲開發者必須用純canvas繪製遊戲內容,這對於遊戲效能的提升是巨大的

同時,為了保留對遊戲引擎的支援和減少現行大量H5遊戲的遷移工作,微信小遊戲官方提供了weapp-adapter介面卡,通過微信小遊戲官方的介面卡或自行開發編寫的介面卡,可以相容很多的BOM或DOM的API

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

演示

elastic-demo.gif

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

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

H5模式二維碼.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官網

相關文章