在〈手動迷宮〉中們制定了細胞的資料結構,以線段作為牆來繪製迷宮,接下來只要能自動產生細胞資料,就能生成迷宮,這邊要來說明最簡單的二元樹演算(Binary Tree Algorithm)。
二元樹演算
二元樹演算概念很簡單,用它來理解自動生成迷宮的原理是個不錯的出發點,而且可以生成完美迷宮 (Perfect maze),也就是任兩個細胞間只有一條路徑可以互通的迷宮。
以 4 乘 4 迷宮為例,首先,每個牆都還沒打穿,任選一個起點:
現在來丟硬幣吧!如果是正面,就打掉右邊的牆,反面就打掉上面的牆,然後移到下一格,例如,若硬幣丟出了反面,往右一格移動,狀態會變成:
對二元樹演算來說,從哪一格開始,或者下一格是哪格都無所謂,為了便於說明,下一格就都往右吧!假設現在又依序丟出了 正、反,狀態就會變成:
到達最右邊的區塊了,現在該怎麼辦呢?不能打掉右邊的牆了,因為那是迷宮的邊界,只能打掉上面的牆,既然這樣,就表示最右邊一排都不用丟硬幣了,直接打掉上面的牆,那麼就一次處理吧!
類似地,如果是最上一排,不能打掉上面的牆,因此也不用丟硬幣,一律打掉右邊的牆:
還剩底下算來第二排與第三排還沒處理,那就先來到左下二排:
假設硬幣丟出了正、反,並持續往右移動,接著又丟出了反面:
現在只剩一排還沒處理了了,從該排最左邊開始,並丟出正、正、正:
YA!迷宮完成了,入口、出口可以任選,因為任兩個細胞間只有一個路徑連通。
為什麼是二元樹?
來為每個細胞設個中心點,然後將互通的點連接起來:
接著不看牆,只看連接中心點的線段,然後稍微轉個角度:
這不就成了二元樹了嗎?每次選擇打掉一個牆,其實就是在選擇父節點,只能打掉一個牆,表示各節點只能有一個父節點,也就是從某個子節點開始,要往特定一個父裔節點移動,只會有一條路徑。
任兩個節點間要能互通,表示它們會有一個共通的父裔節點,既然從某個子節點開始,要往特定一個父裔節點移動,只會有一條路徑,那就表示任兩節點間只會有一條路徑連通。
無論是哪種迷宮演算,若要生成完美迷宮,就是形成某種樹狀結構,從某個子節點開始,要往特定一個父裔節點移動,只會有一條路徑,從而保證路徑不會形成迴圈。
就二元樹演算來說,每個細胞基本上就只是丟硬幣決定要打掉上牆或右牆,然而這就表示,連通至父節點的方式,只能是往上或往右,這會令迷宮產生偏差(Bias),不管你怎麼改變生成細胞的順序,最後生成的迷宮,根節點一定是在右上角。
想改進這個偏差的話,可以加入更多隨機性或變化,例如,後續文件會示範上下左右隨機行進,而不只是往右或往上。
實作二元樹迷宮
二元樹迷宮是丟硬幣決定打右牆或上牆,相對地,就是丟硬幣決定保留上牆或右牆,基於〈手動迷宮〉的程式基礎,可以如下實作:
function binaryTreeRandomCell(x, y, rows, columns) {
// 最右排
if(x === columns - 1) {
return cell(x, y, Maze.RIGHT_WALL);
}
// 最上排
if(y === 0) {
return cell(x, y, Maze.TOP_WALL);
}
// 隨機選擇保留上牆或右牆
return cell(x, y, random([Maze.TOP_WALL, Maze.RIGHT_WALL]));
}
p5.js 的 random
可以指定清單,每次隨機從清單中挑選一個,用來隨機選擇保留上牆或右牆就很方便。
就二元樹演算來說,每個細胞基本上就只是丟硬幣決定要打掉上牆或右牆,這表示生成細胞的順序不重要,就實作而言,最簡單的方式就是迴圈依序走訪每一個細胞,來調整一下〈手動迷宮〉中的 Maze
實作,加入一個 binaryTree
方法:
class Maze {
constructor(rows, columns) {
this.rows = rows;
this.columns = columns;
}
binaryTree() {
this.cells = [];
for(let y = 0; y < this.rows; y++) {
for(let x = 0; x < this.columns; x++) {
this.cells.push(binaryTreeRandomCell(x, y, this.rows, this.columns));
}
}
}
}
現在可以來自動生成迷宮了,可以調整底下範例的 rows
、columns
,指定生成不同大小的迷宮:
單純地生成迷宮很有趣,若能進一步地將生成的過程以動畫方式展現,就會更有趣了,因為細胞產生的順序,就是加入至 cells
陣列的順序,只要指定 cells
中的細胞要畫出幾個,就可以階段性地展現生成過程,為此,可以實作一個 drawMazeCells
函式:
function drawMazeCells(n, maze, cellWidth, sx = 0, sy = 0, ex = maze.columns - 1, ey = maze.rows - 1) {
// 白色背景
for(let i = 0; i < n; i++) {
const cell = maze.cells[i];
push();
noStroke();
translate(cell.x * cellWidth, cell.y * cellWidth);
square(0, 0, cellWidth);
pop();
}
// 細胞牆面
for(let i = 0; i < n; i++) {
const cell = maze.cells[i];
push();
translate(cell.x * cellWidth, cell.y * cellWidth);
drawCell(cell.wallType, cellWidth);
pop();
}
const totalWidth = cellWidth * maze.columns;
const totalHeight = cellWidth * maze.rows;
line(0, 0, 0, totalHeight);
line(0, totalHeight, totalWidth, totalHeight);
line(totalWidth, totalHeight, totalWidth, 0);
line(totalWidth, 0, 0, 0);
const halfWidth = cellWidth / 2;
push();
textSize(halfWidth);
textAlign(CENTER, CENTER);
text('S', sx * cellWidth + halfWidth, sy * cellWidth + halfWidth);
text('E', ex * cellWidth + halfWidth, ey * cellWidth + halfWidth);
pop();
}
來看看動畫展示生成迷宮的過程:
不過,因為是單純地以列、行順序生成細胞,過程略顯單調了些,後續文件會談到隨機上、下、左、右生成細胞的話,屆時過程就會有更多的變化性。
一個有趣的問題是,就細胞來說,它可能會有幾種狀態呢?嗯!因為牆面有上牆、上右牆、右牆、無牆,有四種狀態?不!只有兩種,別忘了,我們是在每個細胞丟硬幣決定的,只會有正與反兩種狀態,對應至牆面,細胞最後只可能會有上牆或右牆,不會有另外兩種牆面狀態,或者說,二元樹演算法只會用到其中兩種。