隨機迷宮


在〈簡易自動迷宮〉中,使用二元樹演算法介紹了迷宮生成的基 本原理,如果你對 Functional programming 不熟悉,使用二元樹演算法還有個好處,因為它不在乎你走訪區塊的順序,因此你只要使用 OpenSCAD 的 List Comprehensions 語法,在 x、y 方向上遞增,持續地打掉上或右牆,就可以生成迷宮的區塊資料。

然而,如果你想改善偏差,基本上就會遇上一個問題,你必須記錄走過的區塊有哪些,如果你還不單只是想打掉上或右牆,而想要像〈手 動迷宮〉中談到的方式,如果可以往右走,表示沒有右牆,如果可以往上走,表示沒有上牆,如果可以往左走,表示左邊區塊沒有右牆,如果 可以往下走,表示下邊區塊沒有上牆,那你還得變更區塊中的牆面形態。

也就是說,在不能重新指定變數值或者向量內容的 Functional programming 風格中,你得試著改變你的迷宮區塊資料,這乍聽起來不可能,不過,這只是你從命令式的程式語言來看 Functional programming 的 OpenSCAD,才會有的誤解。

建立基礎函式

無論如何,直接從實際的例子來看吧!我就不一步一步改變偏差的問題了,直接來個四個方向隨機走訪,這對你來說會有點難,因為就算是我,也得一步 一步思考,一步一步來,我第二次重寫 OpenSCAD 迷宮大概花了三、四個小時吧!這邊雖然會告訴你怎麼寫,不過你自己能思考、動手寫得出來,應該是另外一回事了。

無論如何,有機會還是建議試著挑戰看看,就算失敗了,對 OpenSCAD 也會有更多的認識(也許因此而放棄?…XD)。

方才談到了,我們要能記錄區塊是否造訪過,這是因為重複造訪區塊,就可能會打掉額外的牆面,使得迴圈不再是完美迷宮,記得迷宮的路徑會組成一棵 樹,打掉額外的牆面會使得路徑形成迴路,也就是樹有些節點連成一個圈圈了,這不會是我們想要的。

因此,我們的區塊資料就多一個 visited 欄位吧!

function block_data(x, y, wall_type, visited) = [x, y, wall_type, visited];

一開始的區塊資料都是有上牆與右牆,而且都是沒造訪過的,這個我們定義一個 starting_maze 函式來做這個動作,為了簡化範例,我們的出口一律設在右上角區塊:

// 建立一個初始的迷宮狀態     
function starting_maze(rows, columns) =  [
    for(y = [1:rows]) 
        for(x = [1:columns]) 
            block_data(
                x, y, 
                // 除了出口只需要上牆外,其他都先建立上牆與右牆
                y == rows && x == columns ? UP_WALL : UP_RIGHT_WALL, 
                // 未造訪狀態
                false 
            )
];

因為我們的迷宮區塊資料會組成一個一維向量,因此,想找出 (x, y) 位置的區塊是否造訪過之前,我們必須要能指定 (x, y),得知想要的區塊資料是在哪個索引位置:

// 找出 (x, y) 位置的區塊資料
function indexOf(x, y, maze, i = 0) =
    i > len(maze) ? -1 : (
        [get_x(maze[i]), get_y(maze[i])] == [x, y] ? i : 
            indexOf(x, y, maze, i + 1)
    );

對!這需要用到遞迴,沒那麼難,如果索引已經超出一維向量的長度了,表示找不到,因此傳回 -1,如果沒超出索引範圍,就看看目前索引位置的區塊資料位置與 (x, y) 是否相同,如果是就傳回索引,要不然就從下個索引再找。

遞不遞迴的其實並不是重點,真正的重點是學會分而治之(Divide and conquer),使用 Functional programming,只是強迫你一定要分而治之而已,你比較難寫出像命令式語言那種又臭又長的漿糊(真要寫出漿糊也還是可以啦!如果你堅持的話,誰能阻止得了你 呢?)。

有了 indexOf,要找出 (x, y) 是否造訪過就簡單了:

// 查詢 (x, y) 是否造訪過
function visited(x, y, maze) = maze[indexOf(x, y, maze)][3];

若要查詢指定的 (x, y) 是否可造訪,我們還要檢查一下是不是超出迷宮邊界了:

// 查詢 (x, y) 是否可造訪
function visitable(x, y, maze, rows, columns) = 
    y > 0 && y <= rows &&     // y 不超出邊界
    x > 0 && x <= columns &&  // x 不超出邊界
    !visited(x, y, maze);     // 未造訪過

如果想將 (x, y) 設定為已造訪,可以使用 set_visited 函式:

// 設定 (x, y) 位罝為已造訪
function set_visited(x, y, maze) = [
    for(b = maze) 
        [x, y] == [get_x(b), get_y(b)] ? 
            [x, y, get_wall_type(b), true] : b
];

接著注意了,我們需要隨機決定往右、往上、往左或往下,雖然你可以用 rands 直接產生 0、1、2丶3 來代表,不過,rands 是亂數,你有可能產生 0、1、1、1、2、3 這樣的序列後,才真正探尋過四個方向,為了效率,我直接使用寫死了 0、1、2、3 可能的排列組合(如果你不想手寫,那就參考〈排 列組合〉用程式來產生吧!)。

寫死了還是亂數嗎?我真正用到亂數的地方,是從這一串排列組合中隨機拿出一個:

// 0(右)、1(上)、2(左)、3(下)
function rand_dirs() =
    [
        [0, 1, 2, 3],
        [0, 1, 3, 2],
        [0, 2, 1, 3],
        [0, 2, 3, 1],
        [0, 3, 1, 2],
        [0, 3, 2, 1],
        [1, 0, 2, 3],
        [1, 0, 3, 2],
        [1, 2, 0, 3],
        [1, 2, 3, 0],
        [1, 3, 0, 2],
        [1, 3, 2, 0],
        [2, 0, 1, 3],
        [2, 0, 3, 1],
        [2, 1, 0, 3],
        [2, 1, 3, 0],
        [2, 3, 0, 1],
        [2, 3, 1, 0],
        [3, 0, 1, 2],
        [3, 0, 2, 1],
        [3, 1, 0, 2],
        [3, 1, 2, 0],
        [3, 2, 0, 1],
        [3, 2, 1, 0]
    ][round(rands(0, 24, 1)[0])];

被隨機取出的其中一個向量,只要依序讀出,就一定會探尋完四個方向,這樣就可以避開方才的問題,接著,我寫了個 next_xnext_y 來取得下個 x 或 y:

// 根據下一個區塊的方向取得 x 位置
function next_x(x, dir) = x + [1, 0, -1, 0][dir];
// 根據下一個區塊的方向取得 y 位置
function next_y(y, dir) = y + [0, 1, 0, -1][dir];

準備工作就到這為止了,接下來要正式進入迷宮資料的建置了…

打牆了!

如果目前是在 (x, y) 位置,而且能往右且往右走的話,那麼右邊的牆要打掉,一個區塊有四種牆面型態,因此我們要判斷四種可能性,對吧?像是如果原本只有上牆,或者原本沒有牆,往右走就不用打牆 了,直接保留原牆面狀態,如果原本有右牆就改為無牆,如果有上與右牆,就改為上牆?

基本上是這樣沒錯,你這麼寫應該也可以產生迷宮,只是比較沒有效率而已,仔細想想,如果沒有右牆,表示右牆曾被拆過,那就是曾從右邊區塊往左走 過,那麼右邊區塊本來就不能再走了,這表示沒有右牆的兩個牆面狀態判斷會是多餘的,因此,就只要判斷有上右牆以及只有右牆的狀態了:

// 往右走,打掉右牆
function go_right_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x, y] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x, y, UP_WALL, visited(x, y, maze)] : 
            [x, y, NO_WALL, visited(x, y, maze)]

    ) : b
]; 

類似地,可以往上而且要往上走時,也只要判斷有上右牆以及上牆這兩種情況:

// 往上走,打掉上牆    
function go_up_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x, y] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x, y, RIGHT_WALL, visited(x, y, maze)] :  
            [x, y, NO_WALL, visited(x, y, maze)]

    ) : b
]; 

往左或往下的話,則是要注意,我們打掉的並不是目前區塊的牆面,而是要前往的下個區塊牆面:

// 往左走,打掉左方區塊的右牆
function go_left_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x - 1, y] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x - 1, y, UP_WALL, visited(x - 1, y, maze)] : 
            [x - 1, y, NO_WALL, visited(x - 1, y, maze)]
    ) : b
]; 

// 往下走,打掉下方區塊的上牆
function go_down_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x, y - 1] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x, y - 1, RIGHT_WALL, visited(x, y - 1, maze)] : 
            [x, y - 1, NO_WALL, visited(x, y - 1, maze)]
    ) : b
]; 

嗯?為什麼都是用三元運算子?因為 OpenSCAD 的函式語法中,不能使用 if...else,只能使用 ?: 來達到相同的效果了,if...else 在某些語言中,也可以是運算式(Expression),如果 OpenSCAD 能支援的話,其實對可讀性會很有幫助。

不過,也許作者的原意是不想要函式又臭又長吧!使用 ?: 寫多層一點的話,就會很難閱讀,這時你就不得不拆出另一個函式來改進可讀性,你不這麼做也可以,只是會寫出漿糊!

接著,就是根據指定的方向來決定要往哪邊走了:

// 0(右)、1(上)、2(左)、3(下)
function try_block(dir, x, y, maze, rows, columns) =
    dir == 0 ? go_right_from(x, y, maze) : (
        dir == 1 ? go_up_from(x, y, maze) : (
            dir == 2 ? go_left_from(x, y, maze) : 
                 go_down_from(x, y, maze)   // 這時 dir 一定是 3

        )
    );

隨機走訪

接著就是重點戲碼了,這邊也是最麻煩的,當我們在 (x, y) 點想繼續走訪迷宮時,必須確定還有可造訪的方向:

// 從 (x, y) 出發,找出可造訪的方向
function visitable_dirs_from(x, y, maze, rows, columns) = [
    for(dir = [0, 1, 2, 3]) 
        if(visitable(next_x(x, dir), next_y(y, dir), maze, maze_rows, columns)) 
            dir
];  

既然目前已經是在 (x, y) 點了,表示這點造訪過了,想要隨機造訪的話,我們使用 rand_dirs 函式取得一組隨機方向,然後從 (x, y) 依序造訪四個方向:

// 從 (x, y) 位罝走訪迷宮
function go_maze(x, y, maze, rows, columns) = 
    // 還有可造訪的方向嗎?
    len(visitable_dirs_from(x, y, maze, rows, columns)) == 0 ? 
        set_visited(x, y, maze)      // 沒路了
        : walk_around_from(          // 從 (x, y) 的隨機方向走走看
            x, y, 
            rand_dirs(),             // 隨機方向
            set_visited(x, y, maze), 
            rows, columns
        );

因為隨機方向有四個,這邊使用索引 i 來計數,從 4 開始,當計數為 0 時,表示四個方向都試過了:

// 試著探尋四個方向
function walk_around_from(x, y, dirs, maze, rows, columns, i = 4) =
    // 四個方向都找完了嗎?
    i > 0 ? 
        // 還有方向可以找
        walk_around_from(x, y, dirs, 
            // 探尋其中一個方向
            try_routes_from(x, y, dirs[4 - i], maze, rows, columns),  
            rows, columns, 
            i - 1) // 記得找下一個方向(遞迴時 i - 1)
        : maze;

其中 try_routes_from 會依指定的方向,看看下個區塊位置是否可造訪,如果可以的話,就使用方才的 try_block 走走看,這時就得到一個新的迷宮狀態了,接著用下個區塊進行下一次的 go_maze

function try_routes_from(x, y, dir, maze, rows, columns) = 
    // 這個方向可以造訪嗎?
    visitable(next_x(x, dir), next_y(y, dir), maze, rows, columns) ?     
        // 可以造訪的話就嘗試一下此方向的區塊
        go_maze(
            next_x(x, dir), next_y(y, dir), 
            try_block(dir, x, y, maze, rows, columns),
            rows, columns
        ) 
        // 不行的話,原區塊資料傳回
        : maze;  

也就是說,這邊的分而治之,就是每次試著從 (x, y) 開始找一個方向走訪迷宮,得到一個新的迷宮狀態,然後基於新的區塊位置 (x', y') 開始找一個方向走訪迷宮,又得到一個新的迷宮狀態,一直到最後每個區塊都走訪過為止。

那麼,最先一開始的迷宮狀態是什麼呢?就是 starting_maze 產生的迷宮,最先一開始的 (x, y) 就是 (1, 1) 囉!

接著,你只要使用以下的程式,就能產生 10 乘 10 的迷宮了:

block_width = 3;
wall_thickness = 1;
maze_rows = 10;
maze_columns = 10;  

maze_blocks = go_maze(
    1, 1,   // 入口
    starting_maze(maze_rows, maze_columns),  
    maze_rows, maze_columns
);

draw_maze(
    maze_rows, 
    maze_columns, 
    maze_blocks, 
    block_width, 
    wall_thickness
);

我們有重新指定任何一個變數的值嗎?沒有,我們有修改既有向量的元素內容嗎?沒有!我們每次都是指定新的值給函式的參數,每次都是產生一個新的 向量,依此記錄程式的狀態。

實際上,也正因為只有這樣,才能持續地記錄程式狀態,你不得不使用函式,也不得不一再地將任務分解再分解,從一開始我就說過了,其實迷宮演算法 本身不難,難的是將你的腦袋轉換為 Functional programming 思維!

Functional programming 的好處?習慣了是有許多好處!不過我不在這邊談論了,有興趣可以網路上找找相關文章,如果因此而有愛的話,表示你已經領會它的好處了,如果沒有愛的話,我說再多也是沒有 的,不是嗎?

來個 30 乘 30 的迷宮吧!

隨機迷宮

為了能完整地參考程式碼,底下列出方才談過的全部程式碼內容:

module line(point1, point2, width = 1, cap_round = true) {
    angle = 90 - atan((point2[1] - point1[1]) / (point2[0] - point1[0]));
    offset_x = 0.5 * width * cos(angle);
    offset_y = 0.5 * width * sin(angle);

    offset1 = [-offset_x, offset_y];
    offset2 = [offset_x, -offset_y];

    if(cap_round) {
        translate(point1) circle(d = width, $fn = 24);
        translate(point2) circle(d = width, $fn = 24);
    }

    polygon(points=[
        point1 + offset1, point2 + offset1,  
        point2 + offset2, point1 + offset2
    ]);
}

module polyline(points, width = 1) {
    module polyline_inner(points, index) {
        if(index < len(points)) {
            line(points[index - 1], points[index], width);
            polyline_inner(points, index + 1);
        }
    }

    polyline_inner(points, 1);
}

// 牆面常數

NO_WALL = 0;       // 無牆
UP_WALL = 1;       // 上牆
RIGHT_WALL = 2;    // 右牆
UP_RIGHT_WALL = 3; // 都有

function block_data(x, y, wall_type, visited) = [x, y, wall_type, visited];
function get_x(block_data) = block_data[0];
function get_y(block_data) = block_data[1];
function get_wall_type(block_data) = block_data[2];

module draw_block(wall_type, block_width, wall_thickness) {
    if(wall_type == UP_WALL || wall_type == UP_RIGHT_WALL) {
        // 畫上牆
        polyline(
            [[0, block_width], [block_width, block_width]], wall_thickness
        ); 
    }

    if(wall_type == RIGHT_WALL || wall_type == UP_RIGHT_WALL) {
        // 畫右牆
        polyline(
            [[block_width, block_width], [block_width, 0]], wall_thickness
        ); 
    }
}

module draw_maze(rows, columns, blocks, block_width, wall_thickness) {
    for(block = blocks) {
        // 將每個畫好的區塊移至對應的位置
        translate([get_x(block) - 1, get_y(block) - 1] * block_width) 
            draw_block(
                get_wall_type(block), 
                block_width, 
                wall_thickness
            );
    }

    // 最底牆
    polyline(
        [[0, 0], [block_width * columns, 0]], 
        wall_thickness);
    // 最左牆
    polyline(
        [[0, block_width], [0, block_width * rows]], 
        wall_thickness);
} 

// 建立一個初始的迷宮狀態     
function starting_maze(rows, columns) =  [
    for(y = [1:rows]) 
        for(x = [1:columns]) 
            block_data(
                x, y, 
                // 除了出口只需要上牆外,其他都先建立上牆與右牆
                y == rows && x == columns ? UP_WALL : UP_RIGHT_WALL, 
                // 未造訪狀態
                false 
            )
];

// 找出 (x, y) 位置的區塊資料
function indexOf(x, y, maze, i = 0) =
    i > len(maze) ? -1 : (
        [get_x(maze[i]), get_y(maze[i])] == [x, y] ? i : 
            indexOf(x, y, maze, i + 1)
    );

// 查詢 (x, y) 是否造訪過
function visited(x, y, maze) = maze[indexOf(x, y, maze)][3];

// 查詢 (x, y) 是否可造訪
function visitable(x, y, maze, rows, columns) = 
    y > 0 && y <= rows &&     // y 不超出邊界
    x > 0 && x <= columns &&  // x 不超出邊界
    !visited(x, y, maze);     // 未造訪過

// 設定 (x, y) 位罝為已造訪
function set_visited(x, y, maze) = [
    for(b = maze) 
        [x, y] == [get_x(b), get_y(b)] ? 
            [x, y, get_wall_type(b), true] : b
];

// 0(右)、1(上)、2(左)、3(下)
function rand_dirs() =
    [
        [0, 1, 2, 3],
        [0, 1, 3, 2],
        [0, 2, 1, 3],
        [0, 2, 3, 1],
        [0, 3, 1, 2],
        [0, 3, 2, 1],
        [1, 0, 2, 3],
        [1, 0, 3, 2],
        [1, 2, 0, 3],
        [1, 2, 3, 0],
        [1, 3, 0, 2],
        [1, 3, 2, 0],
        [2, 0, 1, 3],
        [2, 0, 3, 1],
        [2, 1, 0, 3],
        [2, 1, 3, 0],
        [2, 3, 0, 1],
        [2, 3, 1, 0],
        [3, 0, 1, 2],
        [3, 0, 2, 1],
        [3, 1, 0, 2],
        [3, 1, 2, 0],
        [3, 2, 0, 1],
        [3, 2, 1, 0]
    ][round(rands(0, 24, 1)[0])]; 

// 根據下一個區塊的方向取得 x 位置
function next_x(x, dir) = x + [1, 0, -1, 0][dir];
// 根據下一個區塊的方向取得 y 位置
function next_y(y, dir) = y + [0, 1, 0, -1][dir];

// 往右走,打掉右牆
function go_right_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x, y] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x, y, UP_WALL, visited(x, y, maze)] : 
            [x, y, NO_WALL, visited(x, y, maze)]

    ) : b
]; 

// 往上走,打掉上牆    
function go_up_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x, y] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x, y, RIGHT_WALL, visited(x, y, maze)] :  
            [x, y, NO_WALL, visited(x, y, maze)]

    ) : b
]; 

// 往左走,打掉左方區塊的右牆
function go_left_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x - 1, y] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x - 1, y, UP_WALL, visited(x - 1, y, maze)] : 
            [x - 1, y, NO_WALL, visited(x - 1, y, maze)]
    ) : b
]; 

// 往下走,打掉下方區塊的上牆
function go_down_from(x, y, maze) = [
    for(b = maze) [get_x(b), get_y(b)] == [x, y - 1] ? (
        get_wall_type(b) == UP_RIGHT_WALL ? 
            [x, y - 1, RIGHT_WALL, visited(x, y - 1, maze)] : 
            [x, y - 1, NO_WALL, visited(x, y - 1, maze)]
    ) : b
]; 

// 0(右)、1(上)、2(左)、3(下)
function try_block(dir, x, y, maze, rows, columns) =
    dir == 0 ? go_right_from(x, y, maze) : (
        dir == 1 ? go_up_from(x, y, maze) : (
            dir == 2 ? go_left_from(x, y, maze) : 
                 go_down_from(x, y, maze)   // 這時 dir 一定是 3

        ) 
    );


// 從 (x, y) 出發,找出可造訪的方向
function visitable_dirs_from(x, y, maze, rows, columns) = [
    for(dir = [0, 1, 2, 3]) 
        if(visitable(next_x(x, dir), next_y(y, dir), maze, maze_rows, columns)) 
            dir
];  

// 從 (x, y) 位罝走訪迷宮
function go_maze(x, y, maze, rows, columns) = 
    // 還有可造訪的方向嗎?
    len(visitable_dirs_from(x, y, maze, rows, columns)) == 0 ? 
        set_visited(x, y, maze)      // 沒路了
        : walk_around_from(          // 從 (x, y) 的隨機方向走走看
            x, y, 
            rand_dirs(),             // 隨機方向
            set_visited(x, y, maze), 
            rows, columns
        );

// 試著探尋四個方向
function walk_around_from(x, y, dirs, maze, rows, columns, i = 4) =
    // 四個方向都找完了嗎?
    i > 0 ? 
        // 還有方向可以找
        walk_around_from(x, y, dirs, 
            // 探尋其中一個方向
            try_routes_from(x, y, dirs[4 - i], maze, rows, columns),  
            rows, columns, 
            i - 1) // 記得找下一個方向(遞迴時 i - 1)
        : maze;

function try_routes_from(x, y, dir, maze, rows, columns) = 
    // 這個方向可以造訪嗎?
    visitable(next_x(x, dir), next_y(y, dir), maze, rows, columns) ?     
        // 可以造訪的話就嘗試一下此方向的區塊
        go_maze(
            next_x(x, dir), next_y(y, dir), 
            try_block(dir, x, y, maze, rows, columns),
            rows, columns
        ) 
        // 不行的話,原區塊資料傳回
        : maze;   

block_width = 3;
wall_thickness = 1;
maze_rows = 30;
maze_columns = 30;  

maze_blocks = go_maze(
    1, 1,   // 入口
    starting_maze(maze_rows, maze_columns),  
    maze_rows, maze_columns
);

draw_maze(
    maze_rows, 
    maze_columns, 
    maze_blocks, 
    block_width, 
    wall_thickness
);