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

最后


大家可以自己尝试一下这种生成迷宫的方法。
如果大家有什么问题或者指教,也欢迎来与我交流。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容

  • 本篇将尝使用canvas + wasm画一个迷宫,生成算法主要用到连通集算法,使用wasm主要是为了提升运行效率。...
    极乐君阅读 3,534评论 0 9
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,563评论 25 707
  • 回溯算法 回溯法:也称为试探法,它并不考虑问题规模的大小,而是从问题的最明显的最小规模开始逐步求解出可能的答案,并...
    fredal阅读 13,506评论 0 89
  • 又过了一个单身的情人节,我才不要做个怨声载道,仇视爱情的单身狗,太不可爱也太没有尊严,尽管我单身,我...
    离心岛阅读 274评论 0 2