前言
H5遊戲一直以來,以跨平臺,低體驗著稱,其很大原因在於早期技術方案的不成熟和受限於H5遊戲編碼水平。但現今,Canvas和WebGL的渲染效能已經很好了,合理編碼的情況下,體驗與原生應用遊戲並無區別
由微信小程式衍生且獨立而出的 【微信小遊戲】便是瞄準了Web遊戲渲染,代表著這是未來遊戲製作一個很大方向上的趨勢。微信小遊戲執行環境移除了BOM和DOM,這是一個很有意思的方案,因為這意味著遊戲開發者必須用純canvas繪製遊戲內容,這對於遊戲效能的提升是巨大的
同時,為了保留對遊戲引擎的支援和減少現行大量H5遊戲的遷移工作,微信小遊戲官方提供了weapp-adapter介面卡,通過微信小遊戲官方的介面卡或自行開發編寫的介面卡,可以相容很多的BOM或DOM的API
因為微信小遊戲平臺才剛剛推出,目前網路上大量存在的,包括github上開源的微信小遊戲其實都是微信小程式的網頁版本,和傳統頁遊沒區別,受限於BOM和DOM,效能和體驗上都並不好。本文的主旨在於從零開始,以純Canvas的開發方式,製作一個微信小遊戲上非常流行和好玩的遊戲——【彈一弾】
演示
H5模式演示版本:cheneyweb.github.io/wxgame-elas…
H5模式二維碼,手機掃碼體驗(微信掃碼,瀏覽器掃碼等都可以)
微信小遊戲模式演示版本:需要開啟微信開發者工具匯入工程目錄
思路
【彈一弾】遊戲的核心在於對物理彈動的真實模擬和大量物體元素的碰撞互動,是一個非常有挑戰的遊戲製作
任何的遊戲開發開發離不開遊戲引擎,因為純原生的編碼製作遊戲效率是非常低下的,而且難以維護,所以工欲善其,必先利其器,在開發【彈一弾】的同時,我們還需要先製作一個精簡高效的canvas遊戲引擎(稱之為遊戲引擎是不合適的,因為我們不可能在短時間內完成一個遊戲引擎的開發,這裡只是為了類比了遊戲引擎的少部分功能)
任何的遊戲其本質一定是包含著一個或多個迴圈,這才會有了我們所見的動畫效果,下面先列舉【彈一弾】的開發思路
- 統一的資源定義(包括圖片,音效,音樂)等資源
- 統一的資源載入(初始資源在記憶體中的載入)
- 統一的狀態管理(全域性變數資料的維護,這裡說個題外話,我本人非常不喜歡狀態管理之類的的全域性變數方案,但是在遊戲開發中,這是必須且不得不引入的,因為遊戲程式設計對於狀態變更的需求非常大,合理的使用全域性變數能大大提高編碼效率)
- 統一的資源渲染,繪製呈現
- 全域性物理引擎,負責模擬彈性碰撞實現,實現遊戲核心邏輯
- 物件導向的開發思路,以物體元素作為遊戲內容單位,制定每個物體元素的行為和邏輯
以上的1-4點就是我們需要製作的簡單高效的精簡版“遊戲引擎”,有了1-4的基礎鋪墊後,通過5的引入和6的自定義展開,我們就可以完成【彈一弾】的製作
這裡需要補充說明的是第5點,物理引擎,為了開發【彈一弾】我尋找對比了多款JS物理引擎。**目前的現狀是大部分JS物理引擎都已經處於停止開發維護的狀態,多款知名的JS物理引擎在github上已經多年沒更新。**或許是因為物理引擎的門檻較高和H5遊戲早年的發展不順利導致。但對遊戲來說,物理引擎是非常核心且重要的一環,很多PC和Mobile上的遊戲大作,之所以體驗良好,就是因為有強大的物理引擎作為背後支撐,但是這些大作的物理引擎很多都是商業版本,價格高昂且不開源
不過所幸的是,有一款JS物理引擎很突出,效能和功能很強大,且目前有著持續性的維護,它就是Matter.js。這款物理引擎幾乎是我製作彈一弾的唯一選擇,我個人測試下來問題並不多,有部分問題可以通過了對原始碼的一些修改解決。需要特別說明的是Matter物理引擎也是知名遊戲引擎Laya和Egret的開發常選
實踐
整個開發流程會分七步走,需要注意的是,因為文章篇幅所限,不可能展示所有程式碼,但所有核心流程都會有介紹說明,在文末我會附上專案的github地址,提供大家參考
1、開發環境準備
相比傳統遊戲開發,H5遊戲的開發環境十分簡單輕巧,而且我們不採用商業遊戲引擎,而是純原生開發,所有我們只需要一個關鍵工具:
微信開發者工具
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官網