當初接觸程式建模,第一個想挑戰的目標就是迷宮,由於後來決定使用 OpenSCAD,也就會想要看看,有沒有現成的 OpenSCAD 迷宮程式可以參考,不過,在 Thingiverse 上或其他 3D 網站上並沒有找到,於是只好自幹一個 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
的實作,就會有不同的迷宮產生,這也就是為什麼,我會有那麼多種形狀的 迷
宮作品 了。