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

huangjianke發表於2019-09-04
啟邏輯之高妙,因想象而自由

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

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

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

相關文章