燒腦!JS+Canvas帶你體驗「偶消奇不消」的智商挑戰

啓邏輯之高妙,因想象而自由

層疊拼圖Plus是一款需要空間想象力和邏輯推理能力完美結合的微信小遊戲,偶消奇不消,在簡單的遊戲規則下卻有着無數種可能性,需要你充分發揮想象力去探索,看似簡單卻具有極大的挑戰性和趣味性,這就是其魅力所在!溫馨提示,體驗後再閱讀此文體驗更佳哦!

Talk is cheap. Show me the code

層疊拼圖Plus微信小遊戲採用js+canvas實現,沒有使用任何遊戲引擎,對於初學者來說,也比較容易入門。接下來,我將通過以下幾個點循序漸進的講解層疊拼圖Plus微信小遊戲的實現。

  • 如何解決Canvas繪圖模糊?
  • 如何繪製任意多邊形圖形?
  • 1 + 1 = 0,「偶消奇不消」的效果如何實現?
  • 如何判斷一個點是否在任意多邊形內部 ?
  • 如何判斷遊戲結果是否正確?
  • 排行榜的展示
  • 遊戲性能優化

如何解決Canvas繪圖模糊?

canvas 繪圖時,會從兩個物理像素的中間位置開始繪製並向兩邊擴散 0.5 個物理像素。當設備像素比爲 1 時,一個 1px 的線條實際上佔據了兩個物理像素(每個像素實際上只佔一半),由於不存在 0.5 個像素,所以這兩個像素本來不應該被繪製的部分也被繪製了,於是 1 物理像素的線條變成了 2 物理像素,視覺上就造成了模糊

繪圖模糊的原因知道了,在微信小遊戲裏面又該如何解決呢?

const ratio = wx.getSystemInfoSync().pixelRatio
let ctx = canvas.getContext('2d')
canvas.width = screenWidth * ratio
canvas.height = screenHeight * ratio

ctx.fillStyle = 'black'
ctx.font = `${18 * ratio}px Arial`
ctx.fillText('我是清晰的文字', x * ratio, y * ratio)

ctx.fillStyle = 'red'
ctx.fillRect(x * ratio, y * ratio, width * ratio, height * ratio)

可以看到,我們先通過 wx.getSystemInfoSync().pixelRatio 獲取設備的像素比ratio,然後將在屏 Canvas 的寬度和高度按照所獲取的像素比ratio進行放大,在繪製文字、圖片的時候,座標點 xy 和所要繪製圖形的 widthheight均需要按照像素比 ratio 進行縮放,這樣我們就可以清晰的在高清屏中繪製想要的文字、圖片。

可參考微信官方 縮放策略調整

另外,需要注意的是,這裏的 canvas 是由 weapp-adapter 預先調用 wx.createCanvas() 創建一個上屏 Canvas,並暴露爲一個全局變量 canvas

如何繪製任意多邊形圖形?

任意一個多邊形圖形,是由多個平面座標點所組成的圖形區域。

在遊戲畫布內,我們以左上角爲座標原點 {x: 0, y: 0} ,一個多邊形包含多個單位長度的平面座標點,如:[{ x: 1, y: 3 }, { x: 5, y: 3 }, { x: 3, y: 5 }] 表示爲一個三角形的區域,需要注意的是,xy 並不是真實的平面座標值,而是通過屏幕寬度計算出來的單位長度,在畫布內的真實座標值則爲 {x: x * itemWidth, y: y * itemWidth}

繪製多邊形代碼實現如下:

/**
 * 繪製多邊形
 */
export default class Block {
    constructor() { }
    init(points, itemWidth, ctx) {
        this.points = []
        this.itemWidth = itemWidth // 單位長度
        this.ctx = ctx
        for (let i = 0; i < points.length; i++) {
            let point = points[i]
            this.points.push({
                x: point.x * this.itemWidth,
                y: point.y * this.itemWidth
            })
        }
    }

    draw() {
        this.ctx.globalCompositeOperation = 'xor'
        this.ctx.fillStyle = 'black'
        this.ctx.beginPath()
        this.ctx.moveTo(this.points[0].x, this.points[0].y)
        for (let i = 1; i < this.points.length; i++) {
            let point = this.points[i]
            this.ctx.lineTo(point.x, point.y)
        }
        this.ctx.closePath()
        this.ctx.fill()
    }
}

使用:

let points = [
    [{ x: 4, y: 5 }, { x: 8, y: 9 }, { x: 4, y: 9 }],
    [{ x: 10, y: 8 }, { x: 10, y: 12 }, { x: 6, y: 12 }],
    [{ x: 7, y: 4 }, { x: 11, y: 4 }, { x: 11, y: 8 }]
]
points.map((sub_points) => {
    let block = new Block()
    block.init(sub_points, this.itemWidth, this.ctx)
    block.draw()
})

效果如下圖:

CanvasRenderingContext2D其他使用方法可參考:CanvasRenderingContext2D API 列表

1 + 1 = 0,「偶消奇不消」的效果如何實現?

1 + 1 = 0,是層疊拼圖Plus小遊戲玩法的精髓所在。

有經驗的同學,也許一眼就發現了,1 + 1 = 0 剛好符合通過 異或運算 得出的結果。當然,細心的同學也可能已經發現,在 如何繪製任意多邊形圖形 這一章節內,有一句特殊的代碼:this.ctx.globalCompositeOperation = 'xor',也正是通過設置 CanvasContextglobalCompositeOperation 屬性值爲 xor 便實現了「偶消奇不消」的神奇效果。

globalCompositeOperation

globalCompositeOperation 是指 在繪製新形狀時應用的合成操作的類型,其他效果可參考:globalCompositeOperation 示例

如何判斷一個點是否在任意多邊形內部?

當迴轉數爲 0 時,點在閉合曲線外部。

講到這裏,我們已經知道如何在Canvas畫布內繪製出偶消奇不消效果的層疊圖形了,接下來我們來看下玩家如何移動選中的圖形。我們發現繪製出的圖形對象並沒有提供點擊事件綁定之類的操作,那又如何判斷玩家選中了哪個圖形呢?這裏我們就需要去實現如何判斷玩家觸摸事件的xy座標在哪個多邊形圖形內部區域,從而判斷出玩家選中的是哪一個多邊形圖形。

判斷一個點是否在任意多邊形內部有多種方法,比如:

  • 射線法
  • 面積判別法
  • 叉乘判別法
  • 迴轉數法
  • ...

層疊拼圖Plus小遊戲內,採用的是 迴轉數 法來判斷玩家觸摸點是否在多邊形內部。迴轉數 是拓撲學中的一個基本概念,具有很重要的性質和用途。當然,展開討論 迴轉數 的概念並不在該文的討論範圍內,我們僅需瞭解一個概念:當迴轉數爲 0 時,點在閉合曲線外部。

上面面這張圖動態演示了迴轉數的概念:圖中紅色曲線關於點(人所在位置)的迴轉數爲 2

對於給定的點和多邊形,迴轉數應該怎麼計算呢?

  • 用線段分別連接點和多邊形的全部頂點

  • 計算所有點與相鄰頂點連線的夾角

  • 計算所有夾角和。注意每個夾角都是有方向的,所以有可能是負值

最後根據角度累加值計算迴轉數。360°(2π)相當於一次迴轉。

在使用 JavaScript 實現時,需要注意以下問題:

  • JavaScript 的數只有 64 位雙精度浮點這一種。對於三角函數產生的無理數,浮點數計算不可避免會造成一些誤差,因此在最後計算迴轉數需要做取整操作。
  • 通常情況下,平面直角座標系內一個角的取值範圍是 -π 到 π 這個區間,這也是 JavaScript 三角函數 Math.atan2() 返回值的範圍。但 JavaScript 並不能直接計算任意兩條線的夾角,我們只能先計算兩條線與 x 正軸夾角,再取兩者差值。這個差值的結果就有可能超出 π 這個區間,因此我們還需要處理差值超出取值區間的情況。

代碼實現:

/**
 * 判斷點是否在多邊形內/邊上
 */
isPointInPolygon(p, poly) {
    let px = p.x,
        py = p.y,
        sum = 0

    for (let i = 0, l = poly.length, j = l - 1; i < l; j = i, i++) {
        let sx = poly[i].x,
            sy = poly[i].y,
            tx = poly[j].x,
            ty = poly[j].y

        // 點與多邊形頂點重合或在多邊形的邊上
        if ((sx - px) * (px - tx) >= 0 &&
            (sy - py) * (py - ty) >= 0 &&
            (px - sx) * (ty - sy) === (py - sy) * (tx - sx)) {
            return true
        }

        // 點與相鄰頂點連線的夾角
        let angle = Math.atan2(sy - py, sx - px) - Math.atan2(ty - py, tx - px)

        // 確保夾角不超出取值範圍(-π 到 π)
        if (angle >= Math.PI) {
            angle = angle - Math.PI * 2
        } else if (angle <= -Math.PI) {
            angle = angle + Math.PI * 2
        }
        sum += angle
    }

    // 計算迴轉數並判斷點和多邊形的幾何關係
    return Math.round(sum / Math.PI) === 0 ? false : true
}

注:該章節內容圖片均來自網絡,如有侵權,請告知刪除。另外有興趣的同學可以使用其他方法來實現判斷一個點是否在任意多邊形內部。

如何判斷遊戲結果是否正確?

探索的過程固然精彩,而結果卻更令我們期待

通過前面的介紹我們可以知道,判斷遊戲結果是否正確其實就是比對玩家組合圖形的 xor 結果與目標圖形的 xor 結果。那麼如何求多個多邊形 xor 的結果呢? polygon-clipping 正是爲此而生的。它不僅支持 xor 操作,還有其他的比如:union, intersection, difference 等操作。
層疊拼圖Plus遊戲內通過 polygon-clipping 又是怎樣實現遊戲結果判斷的呢?

  • 目標圖形

多邊形平面座標點集合:

points = [
    [{ x: 6, y: 6 }, { x: 10, y: 6 }, { x: 10, y: 10 }, { x: 6, y: 10 }],
    [{ x: 8, y: 6 }, { x: 10, y: 8 }, { x: 8, y: 10 }, { x: 6, y: 8 }]
]
/**
 * 獲取 多個多邊形 xor 結果
 */
const polygonClipping = require('polygon-clipping')

polygonXor(points) {
    let poly = []
    points.forEach(function (sub_points) {
        let temp = []
        sub_points.forEach(function (point) {
            temp.push([point.x, point.y])
        })
        poly.push([temp])
    })

    let results = polygonClipping.xor(...poly)

    // 找出左上角的點
    let min_x = 100, min_y = 100
    results.forEach(function (sub_results) {
        sub_results.forEach(function (temps) {
            temps.forEach(function (point) {
                if (point[0] < min_x) min_x = point[0]
                if (point[1] < min_y) min_y = point[1]
            })
        })
    })

    // 以左上角爲參考點 多邊形平移至 原點 {x: 0, y: 0}
    results.forEach(function (sub_results) {
        sub_results.forEach(function (temps) {
            temps.forEach(function (point) {
                point[0] -= min_x
                point[1] -= min_y
            })
        })
    })
}
let result = this.polygonXor(points)

xor結果:

[
    [[[0, 0], [2, 0], [0, 2], [0, 0]]],
    [[[0, 2], [2, 4], [0, 4], [0, 2]]],
    [[[2, 0], [4, 0], [4, 2], [2, 0]]],
    [[[2, 4], [4, 2], [4, 4], [2, 4]]]
]

同理計算出玩家操作圖形的xor結果進行比對即可得出答案正確與否。

需要注意的是,獲取玩家的 xor 結果並不能直接拿來與目標圖形xor 結果進行比較,我們需要將xor 的結果以左上角爲參考點將圖形平移至原點內,然後再進行比較,如果結果一致,則代表玩家答案正確。

排行榜的展示

有人的地方就有江湖,有江湖的地方就有排行

在看本章節內容之前,建議先瀏覽一遍排行榜相關的官方文檔:好友排行榜關係鏈數據,以便對相關內容有個大概的瞭解。

  • 開放數據域

開放數據域是一個封閉、獨立的 JavaScript 作用域。要讓代碼運行在開放數據域,需要在 game.json 中添加配置項 openDataContext 指定開放數據域的代碼目錄。添加該配置項表示小遊戲啓用了開放數據域,這將會導致一些限制。

// game.json
{
  "openDataContext": "src/myOpenDataContext"
}
  • 在遊戲內使用 wx.setUserCloudStorage(obj) 對玩家遊戲數據進行託管。
  • 在開放數據域內使用 wx.getFriendCloudStorage(obj)拉取當前用戶所有同玩好友的託管數據
  • 展示關係鏈數據

如果想要展示通過關係鏈 API 獲取到的用戶數據,如繪製排行榜等業務場景,需要將排行榜繪製到 sharedCanvas 上,再在主域將 sharedCanvas 渲染上屏。

// src/myOpenDataContext/index.js
let sharedCanvas = wx.getSharedCanvas()

function drawRankList (data) {
  data.forEach((item, index) => {
    // ...
  })
}

wx.getFriendCloudStorage({
  success: res => {
    let data = res.data
    drawRankList(data)
  }
})

sharedCanvas 是主域和開放數據域都可以訪問的一個離屏畫布。在開放數據域調用 wx.getSharedCanvas() 將返回 sharedCanvas

// src/myOpenDataContext/index.js
let sharedCanvas = wx.getSharedCanvas()
let context = sharedCanvas.getContext('2d')
context.fillStyle = 'red'
context.fillRect(0, 0, 100, 100)

在主域中可以通過開放數據域實例訪問 sharedCanvas,通過 drawImage() 方法可以將 sharedCanvas 繪製到上屏畫布。

// game.js
let openDataContext = wx.getOpenDataContext()
let sharedCanvas = openDataContext.canvas

let canvas = wx.createCanvas()
let context = canvas.getContext('2d')
context.drawImage(sharedCanvas, 0, 0)

sharedCanvas 本質上也是一個離屏 Canvas,而重設 Canvas 的寬高會清空 Canvas 上的內容。所以要通知開放數據域去重繪 sharedCanvas

// game.js
openDataContext.postMessage({
  command: 'render'
})

// src/myOpenDataContext/index.js
openDataContext.onMessage(data => {
  if (data.command === 'render') {
    // 重繪 sharedCanvas
  }
})

需要注意的是:sharedCanvas 的寬高只能在主域設置,不能在開放數據域中設置。

遊戲性能優化

性能優化,簡而言之,就是在不影響系統運行正確性的前提下,使之運行地更快,完成特定功能所需的時間更短。

一款能讓人心情愉悅的遊戲,性能問題必然不能成爲絆腳石。那麼可以從哪些方面對遊戲進行性能優化呢?

離屏 Canvas

層疊拼圖Plus小遊戲內,針對需要大量使用且繪圖繁複的靜態場景,都是使用離屏 Canvas進行繪製的,如首頁網格背景、關卡列表、排名列表等。在微信內 wx.createCanvas() 首次調用創建的是顯示在屏幕上的畫布,之後調用創建的都是離屏畫布。初始化時將靜態場景繪製完備,需要時直接拷貝離屏Canvas的圖像即可。Canvas 繪製本身就是不斷的更新幀從而達到動畫的效果,通過使用離屏 Canvas,就大大減少了一些靜態內容在上屏Canvas的繪製,從而提升了繪製性能。

this.offScreenCanvas = wx.createCanvas()
this.offScreenCanvas.width = this.width * ratio
this.offScreenCanvas.height = this.height * ratio

this.ctx.drawImage(this.offScreenCanvas, x * ratio, y * ratio, this.offScreenCanvas.width, this.offScreenCanvas.height)

內存優化

玩家在遊戲過程中拖動方塊的移動其實就是不斷更新多邊形圖形的座標信息,然後不斷的清空畫布再重新繪製,可以想象,這個繪製是非常頻繁的,按照普通的做法就需要不斷去創建多個新的 Block 對象。針對遊戲中需要頻繁更新的對象,我們可以通過使用對象池的方法進行優化,對象池維護一個裝着空閒對象的池子,如果需要對象的時候,不是直接new,而是從對象池中取出,如果對象池中沒有空閒對象,則新建一個空閒對象,層疊拼圖Plus小遊戲內使用的是官方demo內已經實現的對象池類,實現如下:

const __ = {
  poolDic: Symbol('poolDic')
}

/**
 * 簡易的對象池實現
 * 用於對象的存貯和重複使用
 * 可以有效減少對象創建開銷和避免頻繁的垃圾回收
 * 提高遊戲性能
 */
export default class Pool {
  constructor() {
    this[__.poolDic] = {}
  }

  /**
   * 根據對象標識符
   * 獲取對應的對象池
   */
  getPoolBySign(name) {
    return this[__.poolDic][name] || ( this[__.poolDic][name] = [] )
  }

  /**
   * 根據傳入的對象標識符,查詢對象池
   * 對象池爲空創建新的類,否則從對象池中取
   */
  getItemByClass(name, className) {
    let pool = this.getPoolBySign(name)

    let result = (  pool.length
                  ? pool.shift()
                  : new className()  )

    return result
  }

  /**
   * 將對象回收到對象池
   * 方便後續繼續使用
   */
  recover(name, instance) {
    this.getPoolBySign(name).push(instance)
  }
}

垃圾回收

小遊戲中,JavaScript 中的每一個 CanvasImage 對象都會有一個客戶端層的實際紋理儲存,實際紋理儲存中存放着 CanvasImage 的真實紋理,通常會佔用相當一部分內存。

每個客戶端實際紋理儲存的回收時機依賴於 JavaScript 中的 CanvasImage 對象回收。在 JavaScriptCanvasImage 對象被回收之前,客戶端對應的實際紋理儲存不會被回收。通過調用 wx.triggerGC() 方法,可以加快觸發 JavaScriptCore Garbage Collection(垃圾回收),從而觸發 JavaScript 中沒有引用的 CanvasImage 回收,釋放對應的實際紋理儲存。

GC 具體觸發時機還要取決於 JavaScriptCore 自身機制,並不能保證調用 wx.triggerGC() 能馬上觸發回收,層疊拼圖Plus小遊戲在每局遊戲開始或結束都會觸發一下,及時回收內存垃圾,以保證最良好的遊戲體驗。

多線程 Worker

對於遊戲來說,每幀 16ms 是極其寶貴的,如果有一些可以異步處理的任務,可以放置於 Worker 中運行,待運行結束後,再把結果返回到主線程。Worker 運行於一個單獨的全局上下文與線程中,不能直接調用主線程的方法,Worker 也不具備渲染的能力。 Worker與主線程之間的數據傳輸,雙方使用 Worker.postMessage() 來發送數據,Worker.onMessage() 來接收數據,傳輸的數據並不是直接共享,而是被複制的。

// game.json
{
  "workers": "workers"
}

// 創建worker線程
let worker = worker = wx.createWorker('workers/request/index.js') // 文件名指定 worker 的入口文件路徑,絕對路徑

// 主線程向 Worker 發送消息
worker.postMessage({
  msg: 'hello worker'
})

// 主線程監聽 Worker 返回消息
worker.onMessage(function (res) {
  console.log(res)
})

需要注意的是:Worker 最大併發數量限制爲 1 個,創建下一個前請用 Worker.terminate() 結束當前 Worker

其他 Worker 相關的內容請參考微信官方文檔:多線程 Worker

結語

短短的一篇文章,定不能將層疊拼圖Plus小遊戲的前前後後講明白講透徹,加上文筆有限,有描述不當的地方還望多多海涵。其實最讓人心累的還是軟著的申請過程,由於各種原因前前後後花了將近三個月的時間,本來也想寫一下軟著申請相關的內容,最後發現篇幅有點長,無奈作罷,爭取後面花點時間整理一下我這邊的經驗,希望可以幫助到需要的童鞋。

由於項目結構以及代碼還比較混亂,個人覺得,目前暫時還不適合開源。好在,小遊戲內的所有核心代碼以及遊戲實現思想均已呈上,有興趣的同學如果有相關方面的疑問也可以與我多多交流,大家互相學習,共同進步。

江湖不遠,我們遊戲裏見!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章