手動迷宮


自動生成迷宮是個迷人的主題,在起點與終點間充滿各種路徑,起點與終點又必須能連通,乍看之下很神秘;然而嚴格來說,迷宮演算法不難理解,網路上也找得到各種迷宮演算法,實作時更多時候需要的只是細心。

單元的連通

迷宮設計的起點是,雖然起點與終點間充滿各種路徑,不過迷宮說穿了,並不是路徑的問題,而是迷宮中某單元與鄰居單元間如何連通的問題,具體而言,就是哪個鄰接單元可以連到我這個單元,而我這個單元可以連到哪一個鄰接單元,也就是從來哪,往哪去。

以方形迷宮為例,每個單元就是一個細胞(cell),一開始彼此都是不通的:

手動迷宮

你可能會想,每個單元應該都是長這樣:

手動迷宮

這樣確實也是彼此不通,不過,可以更簡單些,每一格如下也是彼此不通:

手動迷宮

當然,真的排列出來會像是:

手動迷宮

這樣左邊與下面不就沒有牆了嗎?那是繪圖時的問題,繪圖時補上兩條線就可以了,就資料上的關係來說,一個細胞只要有上牆與右牆,彼此排列後就是互不相通了。

在這樣的資料結構下,如果要能通往右邊的細胞,表示沒有右牆,如果可以通往上面的細胞,表示沒有上牆,如果可以往左走,表示左邊的細胞沒有右牆,如果可以往下走,表示下面的細胞沒有上牆。

因此,如果迷宮的每個細胞牆面狀況如下:

上牆,   上牆,   上牆, 上右牆 
右牆, 上右牆,   上牆,   右牆 
無牆,   右牆,   右牆,   右牆 
上牆,   上牆,   右牆,   右牆

你就可以畫出底下的圖案:

手動迷宮

如果左上角為入口,右下角為出口,補上兩條線,就是迷宮了:

手動迷宮

不同的迷宮演算法,就是自動產生細胞時採用的方式不同,不過在探討這些演算法之前,會先來手動設置這些細胞,並根據這些細胞畫出迷宮,這是為了先實作一些基礎程式碼。

迷宮的資料結構

每個單元在這邊稱為一個細胞,包含了它的位置以及牆面類型:

function cell(x, y, wallType) {
    return {x, y, wallType};
}

接著是迷宮,因為將實作方形迷宮,要指定它有幾列(row)幾行(column):

class Maze {
    constructor(rows, columns) {
        this.rows = rows;
        this.columns = columns;
        // 先手動設置
        this.cells = [
            cell(0, 0, Maze.TOP_WALL),   cell(1, 0, Maze.TOP_WALL),       cell(2, 0, Maze.TOP_WALL),   cell(3, 0, Maze.TOP_RIGHT_WALL), 
            cell(0, 1, Maze.RIGHT_WALL), cell(1, 1, Maze.TOP_RIGHT_WALL), cell(2, 1, Maze.TOP_WALL),   cell(3, 1, Maze.RIGHT_WALL), 
            cell(0, 2, Maze.NO_WALL),    cell(1, 2, Maze.RIGHT_WALL),     cell(2, 2, Maze.RIGHT_WALL), cell(3, 2, Maze.RIGHT_WALL), 
            cell(0, 3, Maze.TOP_WALL),   cell(1, 3, Maze.TOP_WALL),       cell(2, 3, Maze.RIGHT_WALL), cell(3, 3, Maze.RIGHT_WALL)
        ];
    }
}

Maze.NO_WALL = 'no_wall';
Maze.TOP_WALL = 'top_wall';
Maze.RIGHT_WALL = 'right_wall';
Maze.TOP_RIGHT_WALL = 'top_right_wall';

可以看到這邊定義了一些常數,可用來設置細胞的牆面類型,這邊先手動設置了每個細胞,後續的文件會自動產生這些細胞。

你可能會想,為什麼這邊使用一維陣列呢?以二維陣列來實作,這樣可以使用列、行指定陣列索引不是比較方便嗎?也是可以!只不過我想要更有彈性一些,這種寫法其實可以不受限於只能用來實作方形迷宮,另一方面,後續在實作自動產生細胞時,產生的順序就是加入一維陣列的順序,不必額外增加程式碼來記錄順序,就可以拿來實作出動畫顯示迷宮生成的過程。

繪製迷宮

迷宮既然是由細胞組成,要能繪製迷宮,總要先能繪製細胞吧!

function drawCell(wallType, cellWidth) {
    if(wallType === Maze.TOP_WALL || wallType === Maze.TOP_RIGHT_WALL) {
        line(0, 0, cellWidth, 0);
    }

    if(wallType === Maze.RIGHT_WALL || wallType === Maze.TOP_RIGHT_WALL) {
        line(cellWidth, 0, cellWidth, cellWidth);
    }    
}

這邊單純就是用線段來代表牆面,繪製出平面迷宮,這邊也可以看到,我將迷宮的資料結構與繪製分開的好處,如果你要繪製更漂亮的牆,或是斜角地圖迷宮,甚至是 3D 迷宮,都是可以的!

接著來繪製整個迷宮:

function drawMaze(maze, cellWidth, sx = 0, sy = 0, ex = maze.columns - 1, ey = maze.rows - 1) {
    // 根據細胞繪製
    maze.cells.forEach(cell => {
        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(cellWidth / 2);
        textAlign(CENTER, CENTER);
        text('S', sx * cellWidth + halfWidth, sy * cellWidth + halfWidth);
        text('E', ex * cellWidth + halfWidth, ey * cellWidth + halfWidth);
    pop();
}

接下來就只要在 p5.js 中設置一下就可以了:

後續不會更動繪圖的部份了,因為要來專注如何自動產生細胞,也如何自動產生 Maze 中的 cells 陣列元素了。