【Unity算法實現】簡單回溯法隨機生成 Tile Based 迷宮

算法學習自 作者eastecho 在IndieNova上發表的文章 簡單的使用回溯法生成 Tile Based 迷宮
我只是簡單做了一下Unity的實現。

基礎算法的簡單說明


簡單說明一下算法步驟:

  1. 先生成基礎地圖。
  2. 選擇基礎地圖的一個點(一個格子),將它標記爲已訪問過的,並將這個點加入到堆棧中儲存起來。
  3. 取出堆棧頂的這個點作爲基準點,檢查附近有沒有未訪問過的點。
  4. 如果附近有未訪問過的點,則隨機選取其中的一個點,將它標記爲已訪問過的,並加入到堆棧中儲存。
  5. 重複第二步,直到出現一個點,基於這個點在附近找不到未訪問過的點了。
  6. 將這個堆棧頂部的點移除,基於新的頂部的點重複第二步。
  7. 當堆棧爲空時,說明迷宮已經生成完畢。

經過以上步驟,我們就可以得到一個完美迷宮,從迷宮中的任何一點都可以到達迷宮中的另外一點。
這個算法就像是忒修斯走米諾斯迷宮,進入迷宮時將毛線頭系在迷宮入口,然後隨意走向能走的地方,直到走到了死衚衕,就順着毛線返回到上一個還能走的地方。
而我們的算法則像是在空地順着路線擺磚塊,直到擺不了磚塊了便返回上一個可以擺磚塊的地方繼續擺磚塊。

適合Tile Based地圖的改進算法


我們先假設我們希望生成的迷宮中有兩種大小相同的部件,地面
牆是不能移動到的部分,地面是玩家可以移動的部分。
假如未被訪問的點是牆,已被訪問的點是地面,我們的算法也可以看作是一個人在佈滿牆的空間裏挖路。
這個時候我們發現,如果使用上一個算法,找到身邊可以挖開的牆壁然後挖開,最後所有的牆壁都會被我們鑿開。
所以我們需要爲我們的牆壁預留空間。
我們稍加改良。
簡單說明一下改良後的算法步驟:

  1. 先生成基礎地圖。
  2. 選擇基礎地圖的一個點(一個格子),將它標記爲已訪問過的,並將這個點加入到堆棧中儲存起來。
  3. 取出堆棧頂的這個點作爲基準點,檢查有沒有與這個點相隔一個格子距離,同時又未訪問過的點。
  4. 如果有這樣的點,則隨機選取其中的一個點,將它標記爲已訪問過的,並加入到堆棧中儲存。
  5. 將這兩個點之間的也標記爲已訪問過的點,但不將這個點放入堆棧。所以需注意,這個時候堆棧頂部的點是剛纔檢查到的,距離一個格子的點,而不是附近的這個點。將中間這個點設置爲已訪問的作用是連通當前點和下一個目標點。
  6. 重複第二步,直到出現一個點,基於這個點在附近距離一個格子的範圍找不到未訪問過的點了。
  7. 將這個堆棧頂部的點移除,基於新的頂部的點重複第二步。
  8. 當堆棧爲空時,說明迷宮已經生成完畢。

不同的地方在於查找下一個目標點的時候,跳過了一個格子。最後只要將已訪問過的點設置爲路面,將未訪問過的點設置爲牆面就可以完成迷宮的生成了。

代碼實現


首先我們先說明一下變量

 c#
    //需要生成地圖的行數
    public int row = 35;
    //需要生成地圖的列數
    public int column = 30;
    //生成地圖的基準點
    public Vector2 originPoint;
    //格子之間的偏移
    public float offset;
    //地面格子預設
    public GameObject floorPrefab;
    //牆壁格子預設
    public GameObject wallPrefab;
    //迷宮的邏輯地圖
    private int[,] _maze;
    //根據邏輯地圖生成的實際地圖
    private GameObject[,] _map;
    //儲存目標點的容器
    private List<Vector2> _moves = new List<Vector2>(); 

首先我們初始化邏輯地圖和實際地圖兩個地圖,然後以(0,0)爲起始點開始找尋下一個目標點。即,我們從(0,0)開始挖牆。

c#
 void Start()
    {
        //初始化地形
        InitTerrain();
    }

void InitTerrain()
    {
        //初始化邏輯地圖
        _maze = new int[row, column];
        //初始化實際地圖
        _map = new GameObject[row, column];
        //以(0,0)爲基準點開始查找目標點生成迷宮
        QueryRoad(0, 0);
    } 

接下來我們來看看關鍵的挖牆部分

c#
void QueryRoad(int r, int c)
    {
        string dirs = "";
        //檢查北面的格子是否被訪問
        if ((r - 2 >= 0) && (_maze[r - 2, c] == 0)) dirs += "N";
        //檢查西面的格子是否被訪問
        if ((c - 2 >= 0) && (_maze[r, c - 2] == 0)) dirs += "W";
        //檢查南面的格子是否被訪問
        if ((r + 2 < row) && (_maze[r + 2, c] == 0)) dirs += "S";
        //檢查東面的格子是否被訪問
        if ((c + 2 < column) && (_maze[r, c + 2] == 0)) dirs += "E";
        
        //如果方位爲空,則說明沒有未訪問的格子了
        if (dirs == "")
        {
            //刪除頂上的這個格子
            _moves.RemoveAt(_moves.Count - 1);
            
            if (_moves.Count == 0)
            {
                //如果容器空了,說明迷宮生成完畢,可以開始繪製迷宮了
                DrawTerrain();
            }
            else
            {
                //否則基於新的點,繼續查找下一個目標點
                QueryRoad((int)_moves[_moves.Count - 1].x, (int)_moves[_moves.Count - 1].y);
            }
        }
        else
        {
            //隨機一個可以被訪問的點
            int ran = Random.Range(0, dirs.Length);
            char dir = dirs[ran];
            
            //連通目標點和當前點之間的這個點
            switch (dir)
            {
                case 'E':
                    //將中間這個點設置爲已訪問的
                    _maze[r, c + 1] = 1;
                    c = c + 2;
                    break;
                case 'S':
                    //將中間這個點設置爲已訪問的
                    _maze[r + 1, c] = 1;
                    r = r + 2;
                    break;
                case 'W':
                    //將中間這個點設置爲已訪問的
                    _maze[r, c - 1] = 1;
                    c = c - 2;
                    break;
                case 'N':
                    //將中間這個點設置爲已訪問的
                    _maze[r - 1, c] = 1;
                    r = r - 2;
                    break;
            }

            //將這個新的目標點設置爲已訪問的
            _maze[r, c] = 1;
            //將這個新的目標點加入容器
            _moves.Add(new Vector2(r, c));
            //基於新的點,繼續查找下一個目標點
            QueryRoad(r, c);
        }
    }

算法原理和之前敘述的是一樣的。
最後只需要將實際地圖根據邏輯地圖繪製出來就好了。

c#
void DrawTerrain()
    {
        for (int i = 0; i < row; i++)
        {
            for (int j = 0; j < column; j++)
            {
                switch (_maze[i, j])
                {
                    case 1:
                        if (_map[i, j] != null)
                        {
                            if (_map[i, j].tag == "Floor")
                            {
                                continue;
                            }else if (_map[i, j].tag == "Wall")
                            {
                                Destroy(_map[i, j]);
                                _map[i, j] = null;
                            }
                        }
                        _map[i, j] = Instantiate(floorPrefab, originPoint + new Vector2(j * offset, i * offset), Quaternion.identity);
                        break;
                    case 0:
                        if (_map[i, j] != null)
                        {
                            if (_map[i, j].tag == "Wall")
                            {
                                continue;
                            }
                            else if (_map[i, j].tag == "Floor")
                            {
                                Destroy(_map[i, j]);
                                _map[i, j] = null;
                            }
                        }
                        _map[i, j] = Instantiate(wallPrefab, originPoint + new Vector2(j * offset, i * offset), Quaternion.identity);
                        break;
                }
            }
        }
    }

如何讓繪製迷宮的過程可見


迷宮一下就出現難免有些無趣,畢竟看着空無一物的地圖逐漸被道路充滿也是一種樂趣。我真心覺得看着迷宮漸漸生成非常令人愉悅。所以提供一種讓迷宮的生成可見的方法。

c#

//用來儲存目標點

    private Vector2 _currentPoint;

這是關鍵變量,將之前的遞歸改爲一幀一次,相當於每一幀挖一塊牆,獲取到了新的目標點之後不會立刻找下一個,而是等到下一幀再執行。

記得增加執行條件,以免無限執行查找。

我這裏設置成如果這個點是(-1,-1)則不執行查找。當我在繪製完成最後的實際地圖後會將這個點設置成(-1,-1)。

那麼首先我們對初始化的部分進行一點修改。

c#

void InitTerrain()
    {
        _maze = new int[row, column];
        _map = new GameObject[row, column];
        //初始化時先將目標點設置爲(0,0)
        _currentPoint = new Vector2(0, 0);
    }

然後我們每一幀根據當前的目標點查找下一個目標點。

c#

void Update()
    {
        //如果當前目標點_currentPoint是合法的話就查找下一個目標點
        if (_currentPoint != new Vector2(-1, -1))
        {
            QueryRoad((int) _currentPoint.x, (int) _currentPoint.y);
        }
    }

然後我們的查找目標點也從遞歸改成僅僅查找下一個目標點。

c#
//修改查找部分,取消遞歸。
    void QueryRoad(int x, int y)
    {
        string dirs = "";
        if ((x - 2 >= 0) && (_maze[x - 2, y] == 0)) dirs += "N";
        if ((y - 2 >= 0) && (_maze[x, y - 2] == 0)) dirs += "W";
        if ((x + 2 < row) && (_maze[x + 2, y] == 0)) dirs += "S";
        if ((y + 2 < column) && (_maze[x, y + 2] == 0)) dirs += "E";

        if (dirs == "")
        {
            _moves.RemoveAt(_moves.Count - 1);
            if (_moves.Count == 0)
            {
                DrawTerrain();
                //這是最後一個點了,這個點以外已經沒有符合條件的點,所以在這次繪製完畢後將不再執行查找。
                _currentPoint = new Vector2(-1, -1);
            }
            else
            {
                //這裏取消遞歸,改爲設置目標點,下一幀再處理這個目標點
                //QueryRoad((int) _moves[_moves.Count - 1].x, (int) _moves[_moves.Count - 1].y);
                //因爲這個目標點附近已經沒有符合要求的點了,所以找到上一個點,並將其設置爲新的目標基點。
                _currentPoint = _moves[_moves.Count - 1];
            }
        }
        else
        {
            int ran = Random.Range(0, dirs.Length);
            char dir = dirs[ran];
            
            switch (dir)
            {
                case 'E':
                    _maze[x, y + 1] = 1;
                    y = y + 2;
                    break;
                case 'S':
                    _maze[x + 1, y] = 1;
                    x = x + 2;
                    break;
                case 'W':
                    _maze[x, y - 1] = 1;
                    y = y - 2;
                    break;
                case 'N':
                    _maze[x - 1, y] = 1;
                    x = x - 2;
                    break;
            }

            _maze[x, y] = 1;
            _moves.Add(new Vector2(x, y));
            //這裏依然取消遞歸
            //QueryRoad(x, y);
            //這裏將目標基點_currentPoint設置爲新的目標點。
            _currentPoint = new Vector2(x, y);
            //每一次找到了新的目標點都要繪製一次實際地圖
            DrawTerrain();
        }
    }

每一次找到了新的目標點都要繪製一次實際地圖,這樣才能把你挖通的道路顯示出來。

下一步就是繪製地圖部分,這一部分沒有什麼變化。

c#

void DrawTerrain()
    {
        for (int i = 0; i < row; i++)
        {
            for (int j = 0; j < column; j++)
            {
                switch (_maze[i, j])
                {
                    case 1:
                        if (_map[i, j] != null)
                        {
                            if (_map[i, j].tag == "Floor")
                            {
                                continue;
                            }else if (_map[i, j].tag == "Wall")
                            {
                                Destroy(_map[i, j]);
                                _map[i, j] = null;
                            }
                        }
                        _map[i, j] = Instantiate(floorPrefab, originPoint + new Vector2(j * offset, i * offset), Quaternion.identity);
                        break;
                    case 0:
                        if (_map[i, j] != null)
                        {
                            if (_map[i, j].tag == "Wall")
                            {
                                continue;
                            }
                            else if (_map[i, j].tag == "Floor")
                            {
                                Destroy(_map[i, j]);
                                _map[i, j] = null;
                            }
                        }
                        _map[i, j] = Instantiate(wallPrefab, originPoint + new Vector2(j * offset, i * offset), Quaternion.identity);
                        break;
                }
            }
        }
    }

最後


大家可以自己嘗試一下這種生成迷宮的方法。
如果大家有什麼問題或者指教,也歡迎來與我交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章