迷宮與遮罩


迷宮一定得是方形的嗎?當然不是!就算〈遞迴回溯迷宮〉是基於行列的隨機演算,透過適當地編碼轉換,也是可以轉為〈蜂巢狀迷宮〉或者其他形狀的迷宮

或許製造不同形狀的迷宮,最簡單的方式是,將迷宮演算結合遮罩,原理很簡單,迷宮是一組細胞組成,事先設定某些細胞不能造訪,就可以將迷宮塑造為不同的形狀。例如:

迷宮與遮罩

這是個 30 x 30 的迷宮,透過底下 30 x 30 的點白圖片轉為遮罩資料產生出來,黑色部份是不能走的:

迷宮與遮罩

圖片轉出來的遮罩資料,基本上是一堆 0 與 1,0 是黑色,1 是白色,圖片轉換我寫了個 img2binary 來處理。

為了簡化說明遮罩資料的部份,就用簡單的愛心為例好了,底下是個 11 x 11 的愛心轉換出來的遮罩資料:

const mask = [
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
    [0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
];

為了能開始走訪迷宮,走訪起點必須是在遮罩中 1 的位置,如果希望迷宮能走訪全部的 1,記得 1 彼此之間必須接續,另外要知道的是,並非所有迷宮演算法都可以使用遮罩,例如〈二元樹迷宮〉使用的二元樹演算就不行,理由很簡單,如果目前已經打通至以下狀態:

迷宮與遮罩

黑色是遮罩,若接下來丟硬幣決定了要打通上牆:

迷宮與遮罩

一旦形成以上狀態,右下角那一格,不能打通上牆(因為遮罩)或右牆(因為邊界),它左邊那格又已決定狀態為打通上牆,最後右下角那一格就只能是無法進入的狀態了。

實作遮罩

遞迴回溯演算可以結合遮罩,而且目前我們的實作,可以很簡單地加入遮罩的功能,首先,為細胞增加一個 Maze.MASK 的可能狀態:

Maze.NO_WALL = 'no_wall';
Maze.TOP_WALL = 'top_wall';
Maze.RIGHT_WALL = 'right_wall';
Maze.TOP_RIGHT_WALL = 'top_right_wall';
Maze.MASK = 'mask';  // 增加一個可能的狀態

接著,寫個 maskCells,將 0、1 二維陣列轉為細胞資料:

function maskCells(mask) {
    const maskCells = [];
    for(let y = 0; y < mask.length; y++) {
        for(let x = 0; x < mask[y].length; x++) {
            if(mask[y][x] === 0) {
               maskCells.push([x, y]);
            }
        }
    }
    return maskCells;
}

Mazebacktracker 實作中,加入 mask 參數,預設值是 [],表示不設定遮罩,若有指定就建立遮罩細胞加入 cells 的最前端:

class Maze {
    constructor(rows, columns) {
        this.rows = rows;
        this.columns = columns;
    }

    backtracker(x = 0, y = 0, mask = []) {
        this.cells = [];

        maskCells(mask).forEach(coord => {
            let [x, y] = coord;
            this.cells.push(cell(x, y, Maze.MASK));
        });

        this.cells.push(cell(x, y, Maze.TOP_RIGHT_WALL));
        backtracker(this);
    }
}

因為 Maze 在走訪迷宮細胞時,若發現 cells 中已經有迷宮細胞,就會視為已走訪,自然地,就不會去走訪那些被加入 cells 的遮罩細胞了。

最後就可以來繪製迷宮了,這只要修改 drawMaze,讓它畫出遮罩細胞以及迷宮細胞就可以了:

function drawMaze(maze, cellWidth) {
    const maskCells = maze.cells.filter(cell => cell.wallType === Maze.MASK);

    // 遮罩細胞
    maskCells.filter(cell => cell.wallType === Maze.MASK)
        .forEach(cell => {
             push();
             translate(cell.x * cellWidth, cell.y * cellWidth); 
             square(0, 0, cellWidth);
             pop();
        });

    // 迷宮細胞
    maze.cells.slice(maskCells.length).forEach(cell => {    
        push();
        translate(cell.x * cellWidth, cell.y * cellWidth);
        drawCell(cell.wallType, cellWidth);
        pop();
    });
}

底下是完整的範例:

你可以將底下的「迷」字遮罩資料,取代以上範例的 mask 部份,就可以畫出一開始看到的「迷」字迷宮:

const mask = [
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0],
    [0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
    [0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0],
    [0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0],
    [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
];