[人工智能] 迷宫生成、寻路及可视化动画

[人工智能] 迷宫生成、寻路及可视化动画演算

  • 前言
  • 数据结构准备
  • 迷宫生成算法
  • 迷宫寻路算法

前言

本次带来迷宫相关的算法,迷宫的算法涉及到不少经典的图论算法,在游戏中NPC这些算法被大量的运用,深入了解和学习这些算法是为开发游戏打下坚实的基础。除了纯算法以外,我还借用了OpenGL将这些算法的演算过程可视化出来,借用这些动画演算,可加深对算法的理解,枯燥的算法一下子有趣了起来呢!

本工程全部源码及可执行程序可在github下载:https://github.com/ZeusYang/Breakout。其中的Maze目录就是本次迷宫的项目文件了,可执行程序exe在Maze/x64/Release下,编译的64位程序,可直接运行。

程序操作说明:1、2、3数字键是生成迷宫指令,分别是深度优先、随机Prim、四叉树分割迷宫生成算法,A/a、B/b、C/c字符键是迷宫寻路指令,分别是深度优先、广度优先、A星算法迷宫寻路。按下之后再按Enter即自动开始对应的操作。注意先生成迷宫再进行寻路,否则没有意义,因为一开始都是封闭墙。

数据结构准备

迷宫本质上是一个二维平面,我们用一个二维数组表示,然后数组中的每个元素都是一个迷宫单元。定义迷宫单元[x,y],每个迷宫单元有上、下、左、右四面墙,初始时四面墙都存在。为了方面,我们定义下面的结构体:

enum Neighbor { LEFT = 0, UP = 1, RIGHT = 2, DOWN = 3 };

struct Cell {//迷宫单元
    int neighbors[4];//四个方向的邻居
    int visited;//记录是否访问过了
    //以下用于寻路算法
    glm::ivec2 prev;//记录前驱
    //用于A星算法的open表、closed表
    bool inOpen, inClosed;
    //启发式函数fn = gn + hn
    //其中gn为起点到n的实际距离,hn为n到终点的哈密顿
    int gn, hn;
    Cell() :visited(0),inOpen(false),inClosed(false) {
        neighbors[LEFT] = neighbors[UP] = neighbors[RIGHT] = neighbors[DOWN] = 0;
    }
};

然后声明一个类–MazeAlgorithm,在这里我们将要实现六个算法,每个算法的数据结构如下:

    const int row, col;//迷宫单元的行、列数
    static std::vector<std::vector<Cell>> cells;//迷宫单元矩阵

    //迷宫生成算法一数据结构:深度优先的栈
    std::stack<glm::ivec2> record;
    //迷宫生成算法二数据结构:随机Prim算法的链表
    std::list<glm::ivec2> prim;
    //迷宫生成算法三数据结构:四叉树广度优先的队列
    std::queue<std::pair<glm::ivec2, glm::ivec2>> recursive;

    //迷宫寻路算法一数据结构:深度优先的栈
    std::stack<glm::ivec2> path_dfs;
    //迷宫寻路算法一数据结构:广度优先的队列
    std::queue<glm::ivec2> path_bfs;
    //迷宫寻路算法一数据结构:A星算法的优先队列
    std::priority_queue<Node,std::vector<Node>,Compare> path_astar;

以上仅仅是一部分,具体的细节请看源码。

迷宫生成算法

这里我实现的迷宫生成算法有三个,分别是:深度优先、随机Prim、四叉树分割。

深度优先

就是表面上的意思,深度优先的方法生成迷宫,当然跟普通的深度优先搜索有点差别,它加入了随机性,先看伪代码:

将起点作为当前迷宫单元并标记为已访问  
while 还存在未标记的迷宫单元
    if 当前迷宫单元有未被访问过的的相邻的迷宫单元  then
        随机选择一个未访问的相邻迷宫单元  
        将当前迷宫单元入栈  
        移除当前迷宫单元与相邻迷宫单元的墙  
        标记相邻迷宫单元并用它作为当前迷宫单元  
    else if 栈不空  
        栈顶的迷宫单元出栈  
        令其成为当前迷宫单元  

算法的主要思想就是,每次在当前迷宫单元中寻找与其相邻的未访问过的迷宫单元,然后选择这些邻居其中的一个访问下去,直到所有的单元都被访问到。就是从起点开始随机走,走不通了就返回上一步,从下一个能走的地方再开始随机走。

那么如何实现呢,我们用一个栈来进行深度优先遍历,栈的元素是数组的下标。下面代码中的frame纯属用于演示动画,可去掉直接得结果,还有栈的初始化请在源代码中Generation_Init()函数查看。

std::stack<glm::ivec2> record;
...

bool MazeAlgorithm::Generator_Dfs() {
    frame = 5;//用于演示动画
    while (!record.empty() && frame--) {//当队列或者frame不减到0时
        cells[cur.x][cur.y].visited = 1;//标记当前的位置为访问过的了
        bool hasNeigh = false;//是否有邻居未访问
        std::vector<std::pair<glm::ivec2,int>> tmp; //记录未访问的邻居, tmp.second代表它是哪个邻居
        glm::ivec2 loc;
        //寻找是否存在未访问的邻居
        for (auto x = 0; x < 4; ++x) {
            loc = glm::ivec2(cur.x + to[x][0], cur.y + to[x][1]);
            if (CouldMove(loc) && !cells[loc.x][loc.y].visited) {//有未访问的邻居
                tmp.push_back({ loc,x });//加入tmp中,然后随机选择一个
                hasNeigh = true;
            }
        }
        if (hasNeigh) {//从未访问的邻居中随机选择一个
            int got = rand() % tmp.size();
            record.push(cur);//当前迷宫单元入栈
            //拆掉cur和tmp[got]之间的墙
            cells[cur.x][cur.y].neighbors[tmp[got].second] = 1;
            cells[tmp[got].first.x][tmp[got].first.y].neighbors[(tmp[got].second + 2) % 4] = 1;
            //令当前标记变为该邻居
            cur = tmp[got].first;
        }
        else {//没找到一个未被访问的邻居,是时候回溯了
            cur = record.top();
            record.pop();
        }
    }
    //栈尾空代表已经结束了
    if (record.empty())return true;
    else return false;
}

然后用OpenGL做出的动画如下:
这里写图片描述
可以看到就是沿着一条路一直走下去,没路再回溯。这就是深度优先。

随机Prim

与深度优先不同,随机Prim算法是随机地在迷宫单元列表中随机选取一个迷宫单元,新加入列表和之前加入列表的迷宫单元有同等的概略被选中。对于选中的迷宫单元,标记为被访问状态,并把它从列表中删除,然后依旧查看它的四面邻居的情况,从所有被访问过的邻居中随机选一个,打通这个邻居和当前迷宫单元之前的墙,对所有未被访问过的邻居我们将其放入列表中。注意到我们有删除操作,但是又要求随机访问。这里我采用了链表,我想了下可以用另一种方法替代,但是对于规模不是非常巨大的来说是几乎没什么差别。废话不多说,伪代码如下:

list = 迷宫单元的列表,这里是索引
set  = 暂存一个迷宫单元的被访问过的邻居
将起点加入listwhile list不空
    从list中随机选一个元素cur
    将cur从list中删除,标记cur的迷宫单元为被访问状态
    对于cur的四个邻居
        该邻居被访问过,加入set中
        否则加入listif set非空
        从中随机选一个,打通cur和被选中的迷宫单元之间的墙

随机Prim的等概率性使得所有的迷宫单元优先级几乎等同,因此其分支更多,生成的迷宫更复杂,难度更大。

bool MazeAlgorithm::Generator_Prim() {
    frame = 5;//frame同上
    //prim为list
    while (!prim.empty() && frame--) {
        //随机从list中选一个
        int choice = rand() % prim.size();
        auto it = prim.begin();
        std::advance(it, (choice == 0) ? 0 : choice);
        cur = *it;
        //标记为已访问过,然后从List删除
        cells[cur.x][cur.y].visited = 1;
        prim.erase(it);
        //查看邻居的情况
        std::vector<std::pair<glm::ivec2, int>> tmp; //记录未访问的邻居
        //四个邻居
        for (auto x = 0; x < 4; ++x) {
            glm::ivec2 loc = glm::ivec2(cur.x + to[x][0], cur.y + to[x][1]);
            if (CouldMove(loc)) {//边界检查
                //被访问过,加入tmp中,接下来要随机抽取这些
                if (cells[loc.x][loc.y].visited == 1)tmp.push_back(std::pair<glm::ivec2, int>(loc, x));
                else if (cells[loc.x][loc.y].visited == 0) {
                    //未被访问过,加入list中,并标记为2,代表他们在list中
                    prim.push_back(loc);
                    cells[loc.x][loc.y].visited = 2;
                }
            }
        }
        //有未被访问过的邻居
        if (!tmp.empty()) {
            //从中随机选一个,打通他们之间的墙
            int got = rand() % tmp.size();
            cells[cur.x][cur.y].neighbors[tmp[got].second] = 1;
            cells[tmp[got].first.x][tmp[got].first.y].neighbors[(tmp[got].second + 2) % 4] = 1;
        }
    }
    if (prim.empty())return true;
    else return false;
}

这里写图片描述
可以看到这种方法有点广度优先的影子,这是因为迷宫单元之间的优先级等同。此算法生成迷宫难度最大。

四叉树分割

在有些地方那个也叫递归分割,但实际上可以不用递归,它的本质上就是一颗四叉树。每一次在当前的迷宫范围内用十字分割成四个子空间,在十字四个方向中随机三个墙上挖洞,随机对每个子空间进行同样的操作,知道子空间不可再分。可以看到原理非常简单,但生成迷宫的效率却是最高的,然后此法生成的迷宫教为简单,直路较多。我们直接对迷宫单元数组进行操作,采用广度优先遍历四叉树的方法,每次划分四个子空间。伪代码如下:

queue = {(r1,r2,c1,c2)|r1为最小行,r2为最大行,c1和c2同理,换成列}
将迷宫矩阵范围(0,rows,0,cols)放入queuewhile queue不空
    从queue取队头元素,出队
    if r1 < r2 且 c1 < c2 then
        在r1和r2之间选取随机数r
        在c1和c2之间选取随机数c
        用(r,c)对该范围进行分割
        在(r,c)的四个方向上随机选三个,打通他们的墙
        然后用(r,c)十字分割当前的范围,将四个子空间入队
    else if r1 < r2
        此时子空间变成了一条竖线,我们只在行方向上进行操作和分割
        然后两个子空间加入队列
    else if c1 < c2
        此时子空间变成了一条横线,我们只在列方向上进行操作和分割
        然后两个子空间加入队列

利用递归实现此算法非常简洁明了,但是我为了能够追踪演算过程采用了bfs方法实现,比较繁琐,如下,更多细节请查看源代码:

bool MazeAlgorithm::Generator_Recursive() {
    frame = 10;
    //recursive是queue,其中的元素为pair<glm::ivec2,glm::ivec2>
    //first为行范围,second为列范围
    while (!recursive.empty() && frame--) {
        std::pair<glm::ivec2, glm::ivec2> head = recursive.front();
        recursive.pop();
        //head.first == head.second情况下变成了一条线,需要特殊处理
        if (head.first.x < head.first.y && head.second.x < head.second.y) {
            glm::ivec2 center;
            //在[head.first,head.first)之间选择一个坐标,根据这个坐标进行分割
            center.x = head.first.x + rand() % (head.first.y - head.first.x);
            center.y = head.second.x + rand() % (head.second.y - head.second.x);
            int subRow[2], subCol[2];//存储四个方向上的随机数
            //在center四个方向上随机选取
            subRow[0] = head.first.x + rand() % (center.x - head.first.x + 1);
            subRow[1] = center.x + 1 + rand() % (head.first.y - center.x);
            subCol[0] = head.second.x + rand() % (center.y - head.second.x + 1);
            subCol[1] = center.y + 1 + rand() % (head.second.y - center.y);
            //获取四个方向上的随机迷宫单元
            glm::ivec2 meta[4];
            meta[LEFT] = glm::ivec2(center.x, subCol[0]);
            meta[UP] = glm::ivec2(subRow[0], center.y);
            meta[RIGHT] = glm::ivec2(center.x, subCol[1]);
            meta[DOWN] = glm::ivec2(subRow[1], center.y);
            int notOpen = rand() % 4;//随机选一个迷宫单元不打通,剩下的三个都打通
            for (auto x = 0; x < 4; ++x) {
                if (x != notOpen) {//在这三个迷宫单元挖洞
                    //左、右打通它的下面,上、下打通它的右面
                    glm::ivec2 near = (x % 2 == 0) ? glm::ivec2(meta[x].x + 1, meta[x].y) 
                        : glm::ivec2(meta[x].x, meta[x].y + 1);
                    //哪面墙
                    int which = (x % 2 == 0) ? DOWN : RIGHT;
                    //打通meta[x]和near之间的墙
                    cells[meta[x].x][meta[x].y].neighbors[which] = 1;
                    cells[near.x][near.y].neighbors[(which + 2) % 4] = 1;
                }
            }
            //然后再对当前的四个子空间进行同样处理,入队
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(head.first.x,center.x),
                glm::ivec2(head.second.x,center.y) }));
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(head.first.x,center.x),
                glm::ivec2(center.y + 1,head.second.y) }));
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(center.x + 1,head.first.y),
                glm::ivec2(head.second.x,center.y) }));
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(center.x + 1,head.first.y),
                glm::ivec2(center.y + 1,head.second.y) }));
        }
        else if (head.first.x < head.first.y) {//子空间变成了一条竖线,其他同上
            int rm = head.first.x + rand() % (head.first.y - head.first.x);
            cells[rm][head.second.x].neighbors[DOWN] = 1;
            cells[rm + 1][head.second.x].neighbors[UP] = 1;
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(head.first.x,rm),
                glm::ivec2(head.second.x,head.second.x) }));
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(rm + 1,head.first.y),
                glm::ivec2(head.second.x,head.second.x) }));
        }
        else if (head.second.x < head.second.y) {//子空间变成了一条横线,其他同上
            int cm = head.second.x + rand() % (head.second.y - head.second.x);
            cells[head.first.x][cm].neighbors[RIGHT] = 1;
            cells[head.first.x][cm+1].neighbors[LEFT] = 1;
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(head.first.x,head.first.x),
                glm::ivec2(head.second.x,cm) }));
            recursive.push(std::pair<glm::ivec2, glm::ivec2>({ glm::ivec2(head.first.x,head.first.x),
                glm::ivec2(cm + 1,head.second.y) }));
        }
    }
    if (recursive.empty()) return true;
    else return false;
}

此方法速度很快,可以看到生成的迷宫较为简单,直路多,适合fps等类的游戏。
这里写图片描述

迷宫寻路算法

这里我实现的迷宫寻路算法有三个,分别是:深度优先搜索、广度优先搜索、A星搜索算法。

深度优先搜索

基本的图算法遍历操作,没什么特别的,深度优先搜索出来的路径不一定是最短的,它遵循的原则是找到就好。

bool MazeAlgorithm::Pathfinding_Dfs() {
    //深度优先遍历寻路
    frame = 10;
    while (!path_dfs.empty() && frame--) {
        glm::ivec2 head = path_dfs.top();
        path_dfs.pop();
        //寻找邻居
        for (auto x = 0; x < 4; ++x) {
            glm::ivec2 loc(head.x + to[x][0], head.y + to[x][1]);
            if (CouldMove(loc) && !cells[loc.x][loc.y].visited //未访问
                && cells[head.x][head.y].neighbors[x] == 1) {//且无墙隔着
                cells[loc.x][loc.y].prev = head;//记录前驱,然后要倒推路径
                path_dfs.push(loc);
                cells[loc.x][loc.y].visited = 1;
                //找到终点了
                if (loc == glm::ivec2(row - 1, col - 1)) {
                    //清空dfs栈,停止搜索
                    while (!path_dfs.empty())path_dfs.pop();
                }
            }
        }
    }
    if (path_dfs.empty()) {//从终点倒退路径到起点
        GetSolution();
        return true;
    }
    else return false;
}

这里写图片描述

广度优先搜索

也是基本的图算法,它像病毒爆发一样向着终点蔓延,广度优先搜索得到的路径是最短的,它遵循的原则是一起找,谁先找到就谁是答案。

bool MazeAlgorithm::Pathfinding_Bfs() {
    //广度优先遍历寻路->最短路径
    frame = 10;
    while (!path_bfs.empty() && frame--) {
        glm::ivec2 head = path_bfs.front();
        path_bfs.pop();
        //寻找邻居
        for (auto x = 0; x < 4; ++x) {
            glm::ivec2 loc(head.x + to[x][0], head.y + to[x][1]);
            if (CouldMove(loc) && !cells[loc.x][loc.y].visited //未访问
                && cells[head.x][head.y].neighbors[x] == 1) {//且无墙隔着
                cells[loc.x][loc.y].prev = head;//记录前驱,然后要倒推路径
                path_bfs.push(loc);
                cells[loc.x][loc.y].visited = 1;
                //找到终点了
                if (loc == glm::ivec2(row - 1, col - 1)) {
                    //清空dfs栈,停止搜索
                    while (!path_bfs.empty())path_bfs.pop();
                }
            }
        }
    }
    if (path_bfs.empty()) {//从终点倒退路径到起点
        GetSolution();
        return true;
    }
    else return false;
}

广度优先遍历像病毒蔓延一样。
这里写图片描述

A星搜索算法

A星搜索算法是比较经典的寻路算法了,我在前面的博文中有一篇关于A星算法,这里不再赘述和。我采用的启发式函数是fn = gn + hn,其中gn为起点到n的实际距离,hn为n到终点的哈密顿距离,采用优先队列实现,更多细节请看源代码。

bool MazeAlgorithm::Pathfinding_Astar() {
    frame = 10;
    while (!path_astar.empty() && frame--) {
        Node head = path_astar.top();
        path_astar.pop();
        //标记为放入closed表
        cells[head.index.x][head.index.y].inOpen = false;
        cells[head.index.x][head.index.y].inClosed = true;
        cells[head.index.x][head.index.y].visited = 1;
        找到终点了
        if (head.index == glm::ivec2(row - 1, col - 1)) {
            //清空queue,停止搜索
            while (!path_astar.empty())path_astar.pop();
            break;
        }
        for (auto x = 0; x < 4; ++x) {//查看邻居
            glm::ivec2 loc(head.index.x + to[x][0], head.index.y + to[x][1]);
            if (CouldMove(loc) && cells[head.index.x][head.index.y].neighbors[x] == 1) {//无墙隔着
                if (cells[loc.x][loc.y].inClosed)continue;//已在closed表中,不管它
                if (!cells[loc.x][loc.y].inOpen) {//不在open表中,加入open表
                    path_astar.push(Node(loc.x, loc.y));
                    cells[loc.x][loc.y].inOpen = true;
                    cells[loc.x][loc.y].prev = head.index;
                    cells[loc.x][loc.y].gn = cells[head.index.x][head.index.y].gn + 1;
                    cells[loc.x][loc.y].hn = DirectLen(loc, glm::ivec2(row - 1, col - 1));
                }
                else {//已在open表中,我们进行比较,然后修改前驱
                    int orig = cells[loc.x][loc.y].gn + cells[loc.x][loc.y].hn;
                    int nows = cells[head.index.x][head.index.y].gn + cells[loc.x][loc.y].hn + 1;
                    if (nows < orig) {
                        cells[loc.x][loc.y].prev = head.index;
                        cells[loc.x][loc.y].gn = cells[head.index.x][head.index.y].gn + 1;
                        cells[loc.x][loc.y].hn = cells[loc.x][loc.y].hn;
                    }
                }
            }
        }
    }
    if (path_astar.empty()) {//从终点倒退路径到起点
        GetSolution();
        return true;
    }
    else return false;
}

A星算法围绕启发式函数进行蔓延。
这里写图片描述

本工程全部源码及可执行程序可在github下载:https://github.com/ZeusYang/Breakout。其中的Maze目录就是本次迷宫的项目文件了,可执行程序exe在Maze/x64/Release下,编译的64位程序,可直接运行。

参考博客:https://blog.csdn.net/juzihongle1/article/details/73135920

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值