如何畫出又快又多的圈圈。
命題
一個遊戲:在一個平面、一定時間內消滅一定的目標。
要實現這個遊戲,我們首先要確定,這些元素使用什麼形狀判定有效點選範圍。因為按照圖示形狀又複雜又沒必要,選擇一個近似的規則幾何圖形即可。
在這裡,我們使用圓形作為目標的形狀。
假設世界觀是圓形互不重疊。那麼會有一下兩種可能性:目標大小是否相同;目標是否靜止。複雜度依次遞增。
本文主要針對目標大小相同、目標靜止的情況進行探討,並會附上目標大小相同且目標靜止、目標大小不同且目標靜止兩種情況的程式碼。
本文的探討不考慮重力因素。有關目標移動的情況,感興趣的可以參考《HTML5 Canvas 基礎教程》。
直線思維
隨機生成一個點,圈好佔地範圍,繼續隨機生成一個點,判斷是否與之前生成的圓形重疊,如果是,則拋棄,如果不是,則繼續。
演算法
生成一個點
1 2 3 4 5 6 7 8 9 10 |
/** * @desc 隨機生成一個點 * @param {w, h} {object} w: 畫布寬,h:畫布高 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {x, y} {object} 點座標 */ function randomPoint ({w, h}) { const x = parseInt(Math.random() * w) const y = parseInt(Math.random() * h) return {x, y} } |
隨機生成的點要排除兩種情況:
- A、距離邊界過近。距離邊界過近的目標可能出現隱藏部分過多而難以點選甚至無法點選的情況。
- B、與已有點重疊。
情況A,需要通過合理地設定單位值與無效範圍來避免。
事實上,在0至畫布寬度之間隨機生成的點保證了目標至少有一半留在畫布中。因此第一種情況可以暫時忽略。
情況B,則涉及到了碰撞檢測。
少壯幾何殘,老大徒傷悲
節操哥在《“等一下,我碰!”——常見的2D碰撞檢測》總結了多種碰撞檢測的方式。在這裡,我們就簡單的過一遍圓形碰撞檢測的原理。
這個原理一句話就能概括:兩個圓形圓心距離是否大於兩個圓形的半徑之和。
翻譯成座標語言就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * @desc 碰撞檢測 * @param pointA {object} A目標座標、半徑 * @param pointB {object} B目標座標、半徑 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {boolean} 是否重疊 */ function testOverlay (pointA, pointB) { const xGap = Math.abs(pointA.x - pointB.x) const yGap = Math.abs(pointA.y - pointB.y) const distance = Math.sqrt(xGap * xGap + yGap * yGap) const rGap = pointA.r + pointB.r return distance >= rGap } |
將之應用到整個畫布上,則需要遍歷現有所有的圓形,以檢測新生成的點是否是有效點。
我們將所有有效點都放入一個陣列中,一到碰撞檢測時就遍歷一次,一旦遇到檢測失敗的點,則意味著這是個無效點;而一旦整個陣列都檢測結束,且所有點都與新生成的這個點距離大於半徑之和,則這個點才是有效點。
流程圖如下:
對應遍歷檢測程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
** * @desc 有效點檢測 * @param pointArr {array} 已有點座標、半徑集合陣列 * @param newPoint {object} 新點座標、半徑 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {boolean} 新點是否有效 */ function testAvailable (pointArr, newPoint) { let arr = Array.from(pointArr) let aval = true while(arr.length > 0) { let lastPoint = arr.pop() if (testOverlay(lastPoint, newPoint)) { aval = false break; } } return aval } |
放大招
在剛才的流程中,沒有做次數限制,結局就是當畫布差不多填滿的時候,它會一直執行下去,但卻再也找不到能填補的空白了。因此我們需要對它的嘗試次數做一個限制,增加一個計數器,每嘗試一次加一。
完善了最初的流程圖,我們可以得到:
將碰撞檢測的流程加進去,就是這樣子的——
按照這個流程圖擼程式碼,思路清晰到飛起哦~
完整程式碼:
- 固定半徑See the Pen 隨機平鋪圓形 by EC (@lyxuncle) on CodePen.
- 隨機半徑See the Pen 隨機平鋪圓形(隨機半徑) by EC (@lyxuncle) on CodePen.
另一種解法
其實上面的演算法,已經夠小遊戲用了。但我們換個思路,是不是會得到更加優秀的程式碼君呢?
隨機生成一個點,圈好佔地範圍。然後再這個點的周圍,尋找N個有效目標,直到沒有地方為止。然後再在這個點周圍生成的有效點的周圍尋找有效點,直到再也找不到其他有效點為止。
這個時候,再到畫布範圍隨機生成一個點,然後重複上一步。直到畫布沒有位置了為止。
這種演算法我們暫且將之稱為廣搜演算法(廣度優先搜尋,Breadth First Search,BFS)與隨機演算法的結合。
生成相對隨機點
生成相對隨機點的思路其實是碰撞檢測的逆推,首先在有效範圍內生成一個 x(或 y)點,同時在有效範圍內隨機生成一個半徑值,根據這兩個值,計算出對應的 y(或 x)點。
x (或 y)軸的有效範圍
我們以 x 軸座標為例。
大家看到,上面的方法描述中,對於座標的要求是在“有效範圍”生成。對於這個有效範圍,第一反應就是以相對點為中心,在兩個目標半徑之和間的範圍。
但是,在第一種演算法中,由於 x 與 y 的取值永遠在畫布範圍內,因此能保證至少4/1個圓形出現在畫布中,不影響使用者的定位與操作。但在這種演算法中,由於是相對取點,如果相對位置已經處於畫布邊緣,那就有極大的可能出現隨機產生的相對點過於超出畫布的情況。
我們可以通過兩種方法來解決這個問題:
一、在有效範圍可以確定的情況下,提前排除這種情況,可以減少無效點生成的次數。這種方法的劣勢在於增加了演算法複雜度。我們姑且將它稱為座標預判斷的演算法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * @desc 生成相對隨機點 * @param prev {object} 參照點座標、半徑 * @param size {object} 畫布長寬、半徑範圍 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {object} 新點座標、半徑 */ function randomRelativePoint (prev, size) { const { maxR, minR} = size const nextR = parseInt(Math.random() * (maxR - minR) + minR) const dia = prev.r + nextR const xGap = prev.x - dia < 0 ? Math.random() * (prev.x + dia) : prev.x + dia > size.w ? Math.random() * (size.w - prev.x + dia) : Math.random() * (dia * 2) const x = prev.x - dia < 0 ? parseInt(xGap) : parseInt(xGap + prev.x - dia) // ... } |
其中對於 x 的隨機生成做了限制。如圖所示,如果相對目標超出了邊界,則將隨機範圍劃定為邊界至 x 加上兩目標半徑之和這個絕對距離之間。
二、在判斷有效點的邏輯中增加座標是否超出畫布(或者更為苛刻)的判斷。這種方法也增加了演算法複雜度,但比上一個方法少了一些計算量,不過會有更多的無效點生成,消耗計數器的計數,可能會導致更多的空白區域。這個演算法我們起名座標後判斷。
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * @desc 生成相對隨機點 * @param prev {object} 參照點座標、半徑 * @param radius {number} 固定半徑 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {object} 新點座標、半徑 */ function randomRelativePoint (prev, radius) { const dia = radius * 2 const xGap = Math.random() * (dia * 2) const x = parseInt(xGap + prev.x - dia) // ... } |
依賴已生成 x(或 y)軸座標推匯出 y(或 x)軸座標
在這個步驟裡,需要考慮的是,根據已知座標與已知半徑值,可以得出兩個 y (或 x)軸座標。對於這兩個可能座標,需要再做一次隨機處理。
以 y 軸座標的求值為例。
首先,求得三角形三邊中的 b 邊長度。
接著,隨機出一個正負值,然後求得最終的 y 軸座標。
同樣的,座標預判斷方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/** * @desc 生成相對隨機點(隨機半徑) * @param prev {object} 參照點座標、半徑 * @param size {object} 畫布長寬、半徑範圍 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {object} 新點座標、半徑 */ function randomRelativePoint (prev, size) { const { maxR, minR} = size const nextR = parseInt(Math.random() * (maxR - minR) + minR) const dia = prev.r + nextR // ... const sign = Math.random() - 0.5 > 0 ? 1 : -1 const yGap = parseInt(Math.sqrt(dia * dia - (prev.x - x) * (prev.x - x))) const y = prev.y - yGap < 0 ? prev.y + yGap : prev.y + yGap > size.h ? prev.y - yGap : yGap * sign + prev.y return {x, y, r: nextR} } |
座標後判斷方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * @desc 生成相對隨機點 * @param prev {object} 參照點座標、半徑 * @param radius {number} 固定半徑 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {object} 新點座標、半徑 */ function randomRelativePoint (prev, radius) { const dia = radius * 2 // ... const sign = Math.random() - 0.5 > 0 ? 1 : -1 const yGap = parseInt(Math.sqrt(dia * dia - (prev.x - x) * (prev.x - x))) const y = yGap * sign + prev.y return {x, y} } |
合到一起,就得到一個生成相對隨機點的方法。
座標預判斷方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/** * @desc 生成相對隨機點(隨機半徑) * @param prev {object} 參照點座標、半徑 * @param size {object} 畫布長寬、半徑範圍 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {object} 新點座標、半徑 */ function randomRelativePoint (prev, size) { const { maxR, minR} = size const nextR = parseInt(Math.random() * (maxR - minR) + minR) const dia = prev.r + nextR const xGap = prev.x - dia < 0 ? Math.random() * (prev.x + dia) : prev.x + dia > size.w ? Math.random() * (size.w - prev.x + dia) : Math.random() * (dia * 2) const x = prev.x - dia < 0 ? parseInt(xGap) : parseInt(xGap + prev.x - dia) const sign = Math.random() - 0.5 > 0 ? 1 : -1 const yGap = parseInt(Math.sqrt(dia * dia - (prev.x - x) * (prev.x - x))) const y = prev.y - yGap < 0 ? prev.y + yGap : prev.y + yGap > size.h ? prev.y - yGap : yGap * sign + prev.y return {x, y, r: nextR} } |
座標後判斷方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/** * @desc 生成相對隨機點 * @param prev {object} 參照點座標、半徑 * @param {minR, maxR} {object} 半徑範圍 * <a href="http://www.jobbole.com/members/wx1409399284">@return</a> {object} 新點座標、半徑 */ function randomRelativePoint (prev, radius) { const dia = radius * 2 const xGap = Math.random() * (dia * 2) const x = parseInt(xGap + prev.x - dia) const sign = Math.random() - 0.5 > 0 ? 1 : -1 const yGap = parseInt(Math.sqrt(dia * dia - (prev.x - x) * (prev.x - x))) const y = yGap * sign + prev.y return {x, y} } |
完整程式碼:
- 固定半徑
- 隨機半徑
兩種演算法的對比
由於廣搜演算法與隨機演算法結合中隨機半徑的兩種演算法效率差距較為明顯,因此只取座標後判斷演算法與隨機演算法對比。
在320×550的畫布上,兩種演算法的效率差距不大,覆蓋率(也就是密度)的差距在10%以內,相當於多畫了4-5個左右圈。
但在2000×2000的畫布上,隨機演算法的效率就遠高於兩種演算法結合的效率。在隨機半徑的情況下,兩種演算法結合的方法有時甚至需要1s的時間。而覆蓋率的差距依然在10%以內,由於畫布的增大,意味著圈的數量差距也隨之增加。
遊戲中的演算法
Human Resource Machine 是一個彙編程式設計的小遊戲,通過羅列程式碼段來完成遊戲中的命題。從最簡單的 in/out,到簡單的數值計算,再到最後的排序演算法的實現,而僅有11個程式設計語句可以使用。
每一關都會對你編寫的程式碼進行數量與效率的評估,你所要做到的就是對數量與效率兼顧到最好。
通過這個遊戲,會讓你對底層資料的儲存與處理有比較深的瞭解,同時對程式碼的優化進行追根溯源。
有人說這遊戲真是反人類,現在的程式碼都講究的是可讀性,以這個遊戲的評判標準,有時候是要以犧牲可讀性為代價的。但在這種極端的情況下比較容易激發大家對於演算法的探索與思考,跳出思維定式,以便找到更佳甚至最佳演算法。畢竟,人家只是個遊戲(雖然過幾天你再開啟程式碼也許就看不懂了)。
完整範例
範例中的程式碼使用的是第二種演算法。從畫布面積來看效率上是沒啥問題的,但其實當時是從第一種演算法改為第二種演算法的,因為當時使用的第一種演算法並沒有這次 demo 整理出來的那樣順利。阿婆主也不明白髮生了什麼,也許是因為沒畫流程圖吧[摳鼻]。