H5遊戲開發:一筆畫
一筆畫是圖論科普中一個著名的問題,它起源於柯尼斯堡七橋問題科普。數學家尤拉在他1736年發表的論文《柯尼斯堡的七橋》中不僅解決了七橋問題,也提出了一筆畫定理,順帶解決了一筆畫問題。用圖論的術語來說,對於一個給定的連通圖科普存在一條恰好包含所有線段並且沒有重複的路徑,這條路徑就是「一筆畫」。
尋找連通圖這條路徑的過程就是「一筆畫」的遊戲過程,如下:
遊戲的實現
「一筆畫」的實現不復雜,筆者把實現過程分成兩步:
- 底圖繪製
- 互動繪製
「底圖繪製」把連通圖以「點線」的形式顯示在畫布上,是遊戲最容易實現的部分;「互動繪製」是使用者繪製解題路徑的過程,這個過程會主要是處理點與點動態成線的邏輯。
底圖繪製
「一筆畫」是多關卡的遊戲模式,筆者決定把關卡(連通圖)的定製以一個配置介面的形式對外暴露。對外暴露關卡介面需要有一套描述連通圖形狀的規範,而在筆者面前有兩個選項:
- 點記法
- 線記法
舉個連通圖 —— 五角星為例來說一下這兩個選項。
點記法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
levels: [ // 當前關卡 { name: "五角星", coords: [ {x: Ax, y: Ay}, {x: Bx, y: By}, {x: Cx, y: Cy}, {x: Dx, y: Dy}, {x: Ex, y: Ey}, {x: Ax, y: Ay} ] } ... ] |
線記法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
levels: [ // 當前關卡 { name: "五角星", lines: [ {x1: Ax, y1: Ay, x2: Bx, y2: By}, {x1: Bx, y1: By, x2: Cx, y2: Cy}, {x1: Cx, y1: Cy, x2: Dx, y2: Dy}, {x1: Dx, y1: Dy, x2: Ex, y2: Ey}, {x1: Ex, y1: Ey, x2: Ax, y2: Ay} ] } ] |
「點記法」記錄關卡通關的一個答案,即端點要按一定的順序存放到陣列 coords
中,它是有序性的記錄。「線記法」通過兩點描述連通圖的線段,它是無序的記錄。「點記法」最大的優勢是表現更簡潔,但它必須記錄一個通關答案,筆者只是關卡的搬運工不是關卡創造者,所以筆者最終選擇了「線記法」。:)
互動繪製
在畫布上繪製路徑,從視覺上說是「選擇或連線連通圖端點」的過程,這個過程需要解決2個問題:
- 手指下是否有端點
- 選中點到待選中點之間能否成線
收集連通圖端點的座標,再監聽手指滑過的座標可以知道「手指下是否有點」。以下虛擬碼是收集端點座標:
1 2 3 4 5 6 7 8 |
// 端點座標資訊 let coords = []; lines.forEach(({x1, y1, x2, y2}) => { // (x1, y1) 在 coords 陣列不存在 if(!isExist(x1, y1)) coords.push([x1, y1]); // (x2, y2) 在 coords 陣列不存在 if(!isExist(x2, y2)) coords.push([x2, y2]); }); |
以下虛擬碼是監聽手指滑動:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
easel.addEventListener("touchmove", e => { let x0 = e.targetTouches[0].pageX, y0 = e.targetTouches[0].pageY; // 端點半徑 ------ 取連通圖端點半徑的2倍,提升移動端體驗 let r = radius * 2; for(let [x, y] of coords){ if(Math.sqrt(Math.pow(x - x0, 2) + Math.pow(y - y0), 2) <= r){ // 手指下有端點,判斷能否連線 if(canConnect(x, y)) { // todo } break; } } }) |
在未繪製任何線段或端點之前,手指滑過的任意端點都會被視作「一筆畫」的起始點;在繪製了線段(或有選中點)後,手指滑過的端點能否與選中點串連成線段需要依據現有條件進行判斷。
上圖,點A與點B可連線成線段,而點A與點C不能連線。筆者把「可以與指定端點連線成線段的端點稱作有效連線點」。連通圖端點的有效連線點從連通圖的線段中提取:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
coords.forEach(coord => { // 有效連線點(座標)掛載在端點座標下 coord.validCoords = []; lines.forEach(({x1, y1, x2, y2}) => { // 座標是當前線段的起點 if(coord.x === x1 && coord.y === y1) { coord.validCoords.push([x2, y2]); } // 座標是當前線段的終點 else if(coord.x === x2 && coord.y === y2) { coord.validCoords.push([x1, y1]); } }) }) |
But…有效連線點只能判斷兩個點是否為底圖的線段,這只是一個靜態的參考,在實際的「互動繪製」中,會遇到以下情況:
如上圖,AB已串連成線段,當前選中點B的有效連線點是 A 與 C。AB 已經連線成線,如果 BA 也串連成線段,那麼線段就重複了,所以此時 BA 不能成線,只有 AC 才能成線。
對選中點而言,它的有效連線點有兩種:
- 與選中點「成線的有效連線點」
- 與選中點「未成線的有效連線點」
其中「未成線的有效連線點」才能參與「互動繪製」,並且它是動態的。
回頭本節內容開頭提的兩個問題「手指下是否有端點」 與 「選中點到待選中點之間能否成線」,其實可合併為一個問題:手指下是否存在「未成線的有效連線點」。只須把監聽手指滑動遍歷的陣列由連通圖所有的端點座標 coords
替換為當前選中點的「未成線的有效連線點」即可。
至此「一筆畫」的主要功能已經實現。可以搶先體驗一下:
https://leeenx.github.io/OneStroke/src/onestroke.html
自動識圖
筆者在錄入關卡配置時,發現一個7條邊以上的連通圖很容易錄錯或錄重線段。筆者在思考能否開發一個自動識別圖形的外掛,畢竟「一筆畫」的圖形是有規則的幾何圖形。
上面的關卡「底圖」,一眼就可以識出三個顏色:
- 白底
- 端點顏色
- 線段顏色
並且這三種顏色在「底圖」的面積大小順序是:白底 > 線段顏色 > 端點顏色。底圖的「採集色值表演算法」很簡單,如下虛擬碼:
1 2 3 4 5 6 7 8 9 10 |
let imageData = ctx.getImageData(); let data = imageData.data; // 色值表 let clrs = new Map(); for(let i = 0, len = data.length; i < len; i += 4) { let [r, g, b, a] = [data[i], data[i + 1], data[i + 2], data[i + 3]]; let key = `rgba(${r}, ${g}, ${b}, ${a})`; let value = clrs.get(key) || {r, g, b, a, count: 0}; clrs.has(key) ? ++value.count : clrs.set(rgba, {r, g, b, a, count}); } |
對於連通圖來說,只要把端點識別出來,連通圖的輪廓也就出來了。
端點識別
理論上,通過採集的「色值表」可以直接把端點的座標識別出來。筆者設計的「端點識別演算法」分以下2步:
- 按畫素掃描底圖直到遇到「端點顏色」的畫素,進入第二步
- 從底圖上清除端點並記錄它的座標,返回繼續第一步
虛擬碼如下:
1 2 3 4 5 6 7 8 9 10 |
for(let i = 0, len = data.length; i < len; i += 4) { let [r, g, b, a] = [data[i], data[i + 1], data[i + 2], data[i + 3]]; // 當前畫素顏色屬於端點 if(isBelongVertex(r, g, b, a)) { // 在 data 中清空端點 vertex = clearVertex(i); // 記錄端點資訊 vertexes.push(vertext); } } |
But… 上面的演算法只能跑無損圖。筆者在使用了一張手機截圖做測試的時候發現,收集到的「色值表」長度為 5000+ !這直接導致端點和線段的色值無法直接獲得。
經過分析,可以發現「色值表」裡絕大多數色值都是相近的,也就是在原來的「採集色值表演算法」的基礎上新增一個近似顏色過濾即可以找出端點和線段的主色。虛擬碼實現如下:
1 2 3 4 5 6 7 8 9 |
let lineColor = vertexColor = {count: 0}; for(let clr of clrs) { // 與底色相近,跳過 if(isBelongBackground(clr)) continue; // 線段是數量第二多的顏色,端點是第三多的顏色 if(clr.count > lineColor.count) { [vertexColor, lineColor] = [lineColor, clr] } } |
取到端點的主色後,再跑一次「端點識別演算法」後居識別出 203 個端點!這是為什麼呢?
上圖是放大5倍後的底圖區域性,藍色端點的周圍和內部充斥著大量噪點(雜色塊)。事實上在「端點識別」過程中,由於噪點的存在,把原本的端點被分解成十幾個或數十個小端點了,以下是跑過「端點識別演算法」後的底圖:
通過上圖,可以直觀地得出一個結論:識別出來的小端點只在目標(大)端點上集中分佈,並且大端點範圍內的小端點疊加交錯。
如果把疊加交錯的小端點歸併成一個大端點,那麼這個大端點將十分接近目標端點。小端點的歸併虛擬碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
for(let i = 0, len = vertexes.length; i < len - 1; ++i) { let vertexA = vertexes[i]; if(vertextA === undefined) continue; // 注意這裡 j = 0 而不是 j = i +1 for(let j = 0; j < len; ++j) { let vertexB = vertexes[j]; if(vertextB === undefined) continue; // 點A與點B有疊加,點B合併到點A並刪除點B if(isCross(vertexA, vertexB)) { vertexA = merge(vertexA, vertexB); delete vertexA; } } } |
加了小端點歸併演算法後,「端點識別」的準確度就上去了。經筆者本地測試已經可以 100% 識別有損的連通圖了。
線段識別
筆者分兩個步驟完成「線段識別」:
- 給定的兩個端點連線成線,並採集連線上N個「樣本點」;
- 遍歷樣本點畫素,如果畫素色值不等於線段色值則表示這兩個端點之間不存線上段
如何採集「樣式點」是個問題,太密集會影響效能;太疏鬆精準度不能保證。
在筆者面前有兩個選擇:N 是常量;N 是變數。
假設 N === 5
。區域性提取「樣式點」如下:
上圖,會識別出三條線段:AB, BC 和 AC。而事實上,AC不能成線,它只是因為 AB 和 BC 視覺上共一線的結果。當然把 N 值向上提高可以解決這個問題,不過 N 作為常量的話,這個常量的取量需要靠經驗來判斷,果然放棄。
為了避免 AB 與 BC 同處一直線時 AC 被識別成線段,其實很簡單 —— 兩個「樣本點」的間隔小於或等於端點直徑。
假設 N = S / (2 * R)
,S 表示兩點的距離,R 表示端點半徑。區域性提取「樣式點」如下:
如上圖,成功地繞過了 AC。「線段識別演算法」的虛擬碼實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
for(let i = 0, len = vertexes.length; i < len - 1; ++i) { let {x: x1, y: y1} = vertexes[i]; for(let j = i + 1; j < len; ++j) { let {x: x2, y: y2} = vertexes[j]; let S = Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); let N = S / (R * 2); let stepX = (x1 - x2) / N, stepY = (y1 - y2) / n; while(--N) { // 樣本點不是線段色 if(!isBelongLine(x1 + N * stepX, y1 + N * stepY)) break; } // 樣本點都合格 ---- 表示兩點成線,儲存 if(0 === N) lines.push({x1, y1, x2, y2}) } } |
效能優化
由於「自動識圖」需要對影象的的畫素點進行掃描,那麼效能確實是個需要關注的問題。筆者設計的「自動識圖演算法」,在識別影象的過程中需要對影象的畫素做兩次掃描:「採集色值表」 與 「採集端點」。在掃描次數上其實很難降低了,但是對於一張 750 * 1334
的底圖來說,「自動識圖演算法」需要遍歷兩次長度為 750 * 1334 * 4 = 4,002,000
的陣列,壓力還是會有的。筆者是從壓縮被掃描陣列的尺寸來提升效能的。
被掃描陣列的尺寸怎麼壓縮?
筆者直接通過縮小畫布的尺寸來達到縮小被掃描陣列尺寸的。虛擬碼如下:
1 2 3 4 5 |
// 要壓縮的倍數 let resolution = 4; let [width, height] = [img.width / resolution >> 0, img.height / resolution >> 0]; ctx.drawImage(img, 0, 0, width, height); let imageData = ctx.getImageData(), data = imageData; |
把源圖片縮小4倍後,得到的圖片畫素陣列只有原來的 4^2 = 16倍
。這在效能上是很大的提升。
使用「自動識圖」的建議
儘管筆者在本地測試的時候可以把所有的「底圖」識別出來,但是並不能保證其它開發者上傳的圖片能否被很好的識別出來。筆者建議,可以把「自動識圖」做為一個單獨的工具使用。
筆者寫了一個「自動識圖」的單獨工具頁面:https://leeenx.github.io/OneStroke/src/plugin.html
可以在這個頁面生成對應的關卡配置。
結語
下面是本文介紹的「一筆畫」的線上 DEMO 的二維碼:
遊戲的原始碼託管在:https://github.com/leeenx/OneStroke
其中游戲實現的主體程式碼在:https://github.com/leeenx/OneStroke/blob/master/src/script/onestroke.es6
自動識圖的程式碼在:https://github.com/leeenx/OneStroke/blob/master/src/script/oneStrokePlugin.es6
感謝耐心閱讀完本文章的讀者。本文僅代表筆者的個人觀點,如有不妥之處請不吝賜教。