[人工智能] 迷宫生成、寻路及可视化动画演算
- 前言
- 数据结构准备
- 迷宫生成算法
- 迷宫寻路算法
前言
本次带来迷宫相关的算法,迷宫的算法涉及到不少经典的图论算法,在游戏中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 = 暂存一个迷宫单元的被访问过的邻居
将起点加入list中
while list不空
从list中随机选一个元素cur
将cur从list中删除,标记cur的迷宫单元为被访问状态
对于cur的四个邻居
该邻居被访问过,加入set中
否则加入list中
if 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)放入queue中
while 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