手動迷宮


當初接觸程式建模,第一個想挑戰的目標就是迷宮,由於後來決定使用 OpenSCAD,也就會想要看看,有沒有現成的 OpenSCAD 迷宮程式可以參考,不過,在 Thingiverse 上或其他 3D 網站上並沒有找到,於是只好自幹一個 Maze generator, pure OpenSCAD 出來了:

Maze generator, pure OpenSCAD

其實嚴格來說,迷宮演算法並不難理解,網路上也找得到各種迷宮演算法,Thingiverse 上也有迷宮產生器,不過,沒有純 OpenSCAD 的版本,通常會是用其他語言,像是 Python 來實作迷宮演算,產生 OpenSCAD 檔案再匯出為模型檔。

原因之一我想是在於,OpenSCAD 的設計典範是 Functional programming,我一開始〈挑 戰 OpenSCAD 迷宮產生器〉時,其實也沒有注意到,因此也是吃了些苦頭。

總之,你得先熟悉一下 Functional programming 的風格,寫起迷宮來才不會處處碰壁,這也是為什麼,我明明很早前就有迷宮作品,卻在記錄 OpenSCAD 心得的時候,將迷宮放在最後再來談,我假設你已經在前面的文件中,對 Functional programming 有一定的認識了。

迷宮的牆

要來設計迷宮了,在這之前先來問一個問題,如何設計以下的圖案?

手動迷宮

感覺很簡單,只要用一堆正方形的框來排列就可以了,對吧!

手動迷宮

其實不用正方形的框,你只要用底下這個來排列:

手動迷宮

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

手動迷宮

這是個小問題,只要補上左邊與底部的線好了,重點在於,其實我們可以將迷宮中的每一個區塊,看成只有上牆與右牆,如果可以往右走,表示沒有右 牆,如果可以往上走,表示沒有上牆,如果可以往左走,表示左邊區塊沒有右牆,如果可以往下走,表示下邊區塊沒有上牆。

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

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

那麼你可以畫出:

手動迷宮

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

手動迷宮

區塊資料結構

知道迷宮的牆基本上如何構造之後,接下來,就可以定義區塊的資料結構了,區塊位置使用與 OpenSCAD 的座標軸一致的方向,也就是 X 軸正方向與 Y 軸正方向,因此上圖中最底部一排區塊定義為 (1, 1)、(2, 1)、(3, 1)、(4, 1),第二排就是 (1, 2)、(2, 2)、(3, 2)、(4, 2)…依此類推,也就是區塊座標使用的是 (x, y) 的方式記錄。

除了區塊座標之外,我們還得記錄每個區塊的牆面,為了方便程式處理,先定義一些牆面相關的常數:

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

因此,每個區塊使用 (x, y, wall_type) 的資料結構來記錄,在 OpenSCAD 中可以使用向量來實現,也就是,方才的 4 乘 4 迷宮,我們可以使用底下的方式來記錄:

// [x, y, wall_type]
maze_blocks =  [
    [1, 4, 1], [2, 4, 1], [3, 4, 1], [4, 4, 1], 
    [1, 3, 2], [2, 3, 3], [3, 3, 1], [4, 3, 2], 
    [1, 2, 0], [2, 2, 2], [3, 2, 2], [4, 2, 2], 
    [1, 1, 1], [2, 1, 1], [3, 1, 2], [4, 1, 2]
];

有人可能會想,為什麼不直接使用索引來表示區塊的位置,像是…

maze_blocks =  [
    [1, 1, 1, 1], 
    [2, 3, 1, 2], 
    [0, 2, 2, 2], 
    [1, 1, 2, 2]
];

這樣就只要記錄牆面型態就好了不是嗎?其實也不是不行,只不過,因為我選擇了區塊位置使用與 OpenSCAD 的座標軸一致的方向,若用上面的方式的話,maze_blocks[0] 會是最上那一排,maze_blocks[3] 會是往下第四排,這跟我選擇的座標方向不一致,用這種方式的話,你就要選擇 Y 向下為正方向的座標會比較方便,而且,由於索引是從 0 開始,這點就要稍加留意一下。

另一方面,處理二維向量其實比較麻煩一些,也許你會說,我選擇的方式,不也是二維向量嗎?表面上看是二維,不過一個 [x, y, wall_type] 其實是一個整體,也就是其實我選擇的方式是一維喔!

maze_blocks =  [b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16];

由於 [x, y, wall_type] 其實是一個整體,後續可以看到,我要將這個手動迷宮擴充為隨機自動產生時,這篇文件中撰寫的程式碼不用太多修改(我會加上一個是否造訪過區塊的記錄,也就是變成 [x, y, wall_type, visited])。

簡單來說,我覺得不處理二維向量,不處理索引的方式會比較直覺、方便而且有彈性,因此,在採取這樣的區塊資料結構下,先撰寫幾個簡便的函式:

function block_data(x, y, wall_type) = [x, y, wall_type];
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];

繪製迷宮

迷宮的牆,基本上就是畫線,因此會用到〈線段〉中實作的 polyline 模組,首先要實作的就是畫出一個區塊就好:

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);
}

在將每個畫好的區塊移至對應的位置時,為什麼還要特別減去 1?不減去 1 也可以,只是我喜歡左下是從原點開始罷了,想要畫出上頭的 4 乘 4 迷宮,以下是完整的程式碼:

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) = [x, y, wall_type];
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);
}

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

// [x, y, wall_type]
maze_blocks =  [
    [1, 4, 1], [2, 4, 1], [3, 4, 1], [4, 4, 1], 
    [1, 3, 2], [2, 3, 3], [3, 3, 1], [4, 3, 2], 
    [1, 2, 0], [2, 2, 2], [3, 2, 2], [4, 2, 2], 
    [1, 1, 1], [2, 1, 1], [3, 1, 2], [4, 1, 2]
];


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

當然,這個迷宮是手動的,也就是你得自行輸入迷宮區塊資料,不過注意到了嗎?如果你有辦法隨機產生 maze_blocks, 這就會是個隨機產生迷宮了,這會是之後文件要介紹的。

從另一個角度來看,maze_blocks 是純資料,而 draw_maze 是純綷的外觀呈現,也就是說,這邊的設計將資料與呈現分開了,只要你改變 draw_maze 的實作,就會有不同的迷宮產生,這也就是為什麼,我會有那麼多種形狀的 迷 宮作品 了。