OpenGL开发2D游戏 & 贪吃蛇智能自动寻食
- 前言
- 简单的框架
- 贪吃蛇AI实现
- 算法实现
- 实现展示
前言
本次带来智能贪吃蛇的实现,以尽最大的可能吃掉食物,甚至最后达到满屏的效果。界面部分采用OpenGL制作,辅以炫酷的粒子效果。整个工程以及可执行exe可以在github下载:
https://github.com/ZeusYang/Breakout
其中的GreedySnake就是本工程项目目录。
简单的框架
Shader类:编译、链接着色器程序,包括顶点着色器、片元着色器、几何着色器,也提供设置GPU中的uniform变量的接口。
Texture2D类:封装了OpenGL的纹理接口,用于从数据中创建纹理,指定纹理格式,并用于绑定。
ResourceManager类:资源管理器,用于管理着色器资源和纹理资源,统一给每个着色器命名进行管理,提供从文件中获取着色器代码进而传入Shader类进行编译以及读取图片数据生成纹理,保存所有着色器程序和纹理的副本。
SpriteRenderer类:渲染精灵,这里是一个2D的四边形,提供一个统一的四边形VAO接口,一个精灵有不同的位置、大小、纹理、旋转角度、颜色。
PostProcessor类:后期特效处理,主要使用OpenGL的帧缓冲技术,将游戏渲染后的画面进一步地处理,这里包括震荡、反相、边缘检测和混沌。
TextRenderer类:文本渲染,用于渲染文字。
GameObject类:游戏物品的高层抽象,每个游戏物品有位置、大小、速度、颜色、旋转度、是否实心、是否被破坏、纹理等属性,每次调用SpriteRenderer类的一个实例的Draw方法渲染GameObject。
SnakeObject类:继承自GameObject类,用于绘制蛇身。
ISoundEngine:第三方库irrKlang的实例,用于播放游戏音效。
Algorithm类:算法类,贪吃蛇AI的全部算法逻辑都在这里。
贪吃蛇AI实现
寻路策略:
我们已知食物的位置和蛇头的位置,那么怎么寻找蛇头到食物的位置呢?方法有多种,如A*算法、宽度优先遍历。这里我采用的是宽度优先遍历,在一个二维的数组上,从食物出发,用宽度优先遍历的方法计算出格子中非蛇身到食物的最短距离。一次bfs之后,这个二维格子上就标记好了到食物的最短距离,bfs就是我们搜索的核心:
bool Algorithm::RefreshBoard(const std::list<Object> &psnake, const Object &pfood,
std::vector<std::vector<int> > &pboard)
{ /*
从食物出发,利用广度优先遍历向四周扩散
从而得到pboard中每个格子到达food的路径长度
*/
std::queue<glm::ivec2> record;
record.push(pfood.Index);
std::vector<std::vector<bool>>visited;
visited.resize(pboard.size(), std::vector<bool>(pboard[0].size(), false));
visited[pfood.Index.x][pfood.Index.y] = true;
glm::ivec2 cur;
bool found = false;
while (!record.empty()) {
glm::ivec2 head = record.front();
record.pop();
//向四个方向扩展
for (auto x = 0; x < 4; ++x) {
cur = glm::ivec2(head.x + dir[x][0], head.y + dir[x][1]);
//碰到边界或已经访问过了
if (!CouldMove(cur) || visited[cur.x][cur.y])continue;
if (cur == psnake.front().Index)found = true;//找到蛇头
if (pboard[cur.x][cur.y] < SNAKE) {//不是蛇身
pboard[cur.x][cur.y] = pboard[head.x][head.y] + 1;
record.push(cur);
visited[cur.x][cur.y] = true;
}
}
}
return found;
}
贪吃蛇寻食策略
上面已经给出了一种搜索算法,但是简单的使用bfs算法只能使蛇运行非常短的一段时间,一段时间之后它就被自己的身体困住了。每次都单纯地使用BFS,最终有一天, 贪吃蛇会因为这种不顾后果的短视行为而陷入困境。
聪明的蛇会考虑吃食物的后果,也就是吃完这个食物自己还安全吗?那么自己定义安全的局面呢?我们知道,蛇头在移动的过程中,蛇尾部分是不断地有空位空出来的,蛇永远不死的最好策略就是追着自己的尾巴跑!现在我们定义这样的安全策略,如果蛇吃完这个食物之后,蛇头到蛇尾之间有通路的话,那么就定义为安全的!
每次寻找到食物的一个路径,我们就模拟蛇头移动过去吃食物了,然后再用bfs算法搜索蛇头到蛇尾之间是否存在通路。这是我们目前的策略。值得注意的是,蛇每走一步,整个蛇身会移动,也就是说蛇在移动的过程中,整个局面是不断变化,所以我们不能只一次bfs就够了,而是每走一步,我们按照前面的策略模拟一次,不断寻找安全的路径!
那么现在我们的问题是,如果蛇和食物之间不存在安全的路径或蛇和食物之间根本就没有通路该如何?也就是说吃完食物之后蛇头和蛇尾没有通路了或根本吃不到食物,这种情况下蛇可能很快就被自己饶进了死胡同然后over了。这时我们先用远大的目光,暂时不要去管食物,我们先追着蛇尾跑,在追着蛇尾的过程中出现安全的路径我们再过去吃食物。
现在新的问题又来了,如果蛇和食物之间没有路径且蛇头和蛇尾之间也没有路径该怎么办?这个时候没什么办法了,只能将就地走走停停,每次只走一步,更新布局,然后再判断蛇和食物间是否有安全路径; 没有的话,蛇头和蛇尾间是否存在路径;还没有,再挑一步可行的来走。
一般来说,我们让蛇头和食物之间的路径尽可能短,就是快点吃掉食物,而蛇头和蛇尾之间的路尽可能地长,尽可能地慢。这样蛇头和蛇尾间才能腾出更多的空间,空间多才有得发展。 所以针对食物和蛇尾,我们有:目标是食物时,选最短路径;目标是蛇尾时,选最长路径。
好的现在我们整理一下整个贪吃蛇AI的策略:
If hasPath(head,food) && safe(head,tail):
then go one step ahead toward food.
else if hasPath(head,tail(
then go one step ahead toward tail.
else
just find any possible step to go.
算法实现
核心部分:
//AI思考
glm::ivec2 Algorithm::AIThinking() {
ResetBoard(snake, *food, board);
glm::ivec2 move;
if (RefreshBoard(snake, *food, board))//可以吃到食物
move = FindSafeWay();//找到一条安全的路
else
move = FollowTail();//不可吃到食物,跟随尾巴
if(move == glm::ivec2(-1,-1))//不能跟随尾巴,任意路径
move = AnyPossibleWay();
return move;
}
if (this->State == GAME_ACTIVE ) {//AI模式
//AI策略
glm::ivec2 move = algorithm->AIThinking();
//没找到任何路径,游戏结束
if (move == glm::ivec2(-1, -1))this->State = GAME_LOST;
else {
//走出一步
bool isCollision = algorithm->make_move(move);
if (isCollision) {//碰撞,boom
fireindex = (fireindex + 1) % 3;
firetimer[fireindex] = 2.0f;
firework->Position = food->Position;
boom[fireindex]->Reset();
boom[fireindex]->Update(0.f, *firework, 400, firework->Size / 2.0f, 3, fireindex);
sound->play2D("../res/Audio/get.wav", GL_FALSE);
//获取一分
++score;
}
if (algorithm->win) {//满屏了
State = GAME_WIN;
return;
}
food->Position = Index(algorithm->food->Index);//更新食物位置
}
}
算法类:
struct Object {
glm::ivec2 Index;//数组下标
glm::vec3 Color;//颜色
Object(int r,int c) :Index(r, c) {
int decision = rand() % 4;
switch (decision) {
case 0:Color = glm::vec3(0.2f, 0.6f, 1.0f); break;
case 1:Color = glm::vec3(0.0f, 0.7f, 0.0f); break;
case 2:Color = glm::vec3(0.8f, 0.8f, 0.4f); break;
case 3:Color = glm::vec3(1.0f, 0.5f, 0.0f); break;
default:
Color = glm::vec3(1.0f, 0.5f, 0.0f); break;
}
}
};
//算法逻辑类
class Algorithm{
public:
Algorithm(GLuint x,GLuint y);
//随机产生新的食物
glm::ivec2 NewFood();
//重置
void ResetBoard(const std::list<Object> &psnake, const Object &pfood,
std::vector<std::vector<int> > &pboard);
void ResetSnakeAndFood();
//广度优先遍历整个board的情况
bool RefreshBoard(const std::list<Object> &psnake, const Object &pfood,
std::vector<std::vector<int> > &pboard);
glm::ivec2 FindSafeWay();//找到一条安全的路径
glm::ivec2 AnyPossibleWay();//随便找一条路
glm::ivec2 AIThinking();//AI思考
void Display();
bool make_move(glm::ivec2 step);//移动蛇身
void VirtualMove();//虚拟探测性检测
bool IsTailInside();//评测是否蛇尾和蛇头之间有路径
glm::ivec2 FollowTail();//朝蛇尾方向走
std::list<Object> snake;//蛇
std::shared_ptr<Object> food;//食物
bool win;
private:
//行数、列数
GLuint row, col;
std::vector<std::vector<int> >board;//用来标记board中每个位置的状况,0是空的,1是蛇身,2是食物
//虚拟记录贪吃蛇的情况
std::vector<std::vector<int> >tmpboard;
std::list<Object> tmpsnake;
int EMPTY, SNAKE, FOOD;
//边界判断
inline bool CouldMove(glm::ivec2 &target) {
if (target.x < 0 || target.x >= row)return false;
if (target.y < 0 || target.y >= col)return false;
return true;
}
//二维数组的结点向上、下、左、右四个扩展方向
const int dir[4][2] = {
{ -1,0 },{ +1,0 },{ 0,-1 },{ 0,+1 }
};
//找到一条最短的路径的方向
inline glm::ivec2 ShortestMove(glm::ivec2 target,
const std::vector<std::vector<int> > &pboard){
int minv = SNAKE;
glm::ivec2 move(-1,-1);
for (auto x = 0; x < 4; ++x) {
glm::ivec2 tmp = glm::ivec2(target.x + dir[x][0], target.y + dir[x][1]);
if (CouldMove(tmp) && minv > pboard[tmp.x][tmp.y]) {
minv = pboard[tmp.x][tmp.y];
move = tmp;
}
}
return move;
}
//找到一条最长的路径的方向
inline glm::ivec2 LongestMove(glm::ivec2 target,
const std::vector<std::vector<int> > &pboard) {
int mxav = -1;
glm::ivec2 move(-1, -1);
for (auto x = 0; x < 4; ++x) {
glm::ivec2 tmp = glm::ivec2(target.x + dir[x][0], target.y + dir[x][1]);
if (CouldMove(tmp) && pboard[tmp.x][tmp.y] < EMPTY && mxav < pboard[tmp.x][tmp.y]) {
mxav = pboard[tmp.x][tmp.y];
move = tmp;
}
}
return move;
}
};
Algorithm::Algorithm(GLuint x, GLuint y)
:row(x), col(y), FOOD(0), EMPTY((row + 1)*(col + 1)),
SNAKE(2 * EMPTY)
{
food = std::make_shared<Object>(NewFood().x, NewFood().y);
board.resize(row, std::vector<int>(col, EMPTY));
win = false;
}
void Algorithm::ResetBoard(const std::list<Object> &psnake, const Object &pfood,
std::vector<std::vector<int> > &pboard) {
for (auto &t : pboard)
std::fill(t.begin(), t.end(), EMPTY);
pboard[pfood.Index.x][pfood.Index.y] = FOOD;
for (auto &t : psnake)
pboard[t.Index.x][t.Index.y] = SNAKE;
}
glm::ivec2 Algorithm::NewFood() {
glm::ivec2 loc;
loc.x = rand() % row;
loc.y = rand() % col;
while (true) {
bool found = false;
for (auto &x : snake) {
if (loc == x.Index) {
found = true;
break;
}
}
if (!found)return loc;
loc.x = rand() % row;
loc.y = rand() % col;
}
return loc;
}
void Algorithm::Display() {
for (auto &t : board) {
for (auto &x : t) {
std::cout << x << "-";
}
std::cout << "\n";
}
}
bool Algorithm::RefreshBoard(const std::list<Object> &psnake, const Object &pfood,
std::vector<std::vector<int> > &pboard)
{ /*
从食物出发,利用广度优先遍历向四周扩散
从而得到pboard中每个格子到达food的路径长度
*/
std::queue<glm::ivec2> record;
record.push(pfood.Index);
std::vector<std::vector<bool>>visited;
visited.resize(pboard.size(), std::vector<bool>(pboard[0].size(), false));
visited[pfood.Index.x][pfood.Index.y] = true;
glm::ivec2 cur;
bool found = false;
while (!record.empty()) {
glm::ivec2 head = record.front();
record.pop();
//向四个方向扩展
for (auto x = 0; x < 4; ++x) {
cur = glm::ivec2(head.x + dir[x][0], head.y + dir[x][1]);
//碰到边界或已经访问过了
if (!CouldMove(cur) || visited[cur.x][cur.y])continue;
if (cur == psnake.front().Index)found = true;//找到蛇头
if (pboard[cur.x][cur.y] < SNAKE) {//不是蛇身
pboard[cur.x][cur.y] = pboard[head.x][head.y] + 1;
record.push(cur);
visited[cur.x][cur.y] = true;
}
}
}
return found;
}
bool Algorithm::make_move(glm::ivec2 step) {
//直接加入前面
snake.push_front(Object(step.x,step.y));
//如果加的不是食物位置,删掉最后一个
if (snake.front().Index != food->Index) {
snake.pop_back();
}
else {//如果吃到食物
if (snake.size() == row*col) {
win = true;
return true;
}
food->Index = NewFood();//重新产生一个新的食物
return true;
}
return false;
}
glm::ivec2 Algorithm::AnyPossibleWay() {
glm::ivec2 ret = glm::ivec2(-1,-1);
ResetBoard(snake, *food, board);
RefreshBoard(snake, *food, board);
int minv = SNAKE;
for (auto x = 0; x < 4; ++x) {
glm::ivec2 tmp = glm::ivec2(snake.front().Index.x + dir[x][0], snake.front().Index.y + dir[x][1]);
if (CouldMove(tmp) && minv > board[tmp.x][tmp.y]) {
minv = board[tmp.x][tmp.y];
ret = tmp;
}
}
return ret;
}
void Algorithm::VirtualMove() {
tmpsnake = snake;
tmpboard = board;
ResetBoard(tmpsnake, *food, tmpboard);
bool eaten = false;
glm::ivec2 move;
while (!eaten) {//已确保蛇与食物有路径,所以不会陷入死循环
//搜索路径
RefreshBoard(tmpsnake, *food, tmpboard);
move = ShortestMove(tmpsnake.front().Index, tmpboard);//找到最短的一步
tmpsnake.push_front(Object(move.x, move.y));//加入蛇头
if (move == food->Index) {//如果走到了食物那里
eaten = true;
ResetBoard(tmpsnake, *food, tmpboard);
tmpboard[food->Index.x][food->Index.y] = SNAKE;//食物被蛇吃掉了
}
else {//还没吃到食物
tmpsnake.pop_back();
}
}
}
bool Algorithm::IsTailInside() {
//将蛇尾看成食物
tmpboard[tmpsnake.back().Index.x][tmpsnake.back().Index.y] = FOOD;
tmpboard[food->Index.x][food->Index.y] = SNAKE;
Object tail(tmpsnake.back().Index.x, tmpsnake.back().Index.y);
bool ret = RefreshBoard(tmpsnake, tail, tmpboard);
for (auto x = 0; x < 4; ++x) {
glm::ivec2 tmp = glm::ivec2(tmpsnake.front().Index.x + dir[x][0], tmpsnake.front().Index.y + dir[x][1]);
if (CouldMove(tmp) && tmp == tail.Index)ret = false;
}
return ret;
}
glm::ivec2 Algorithm::FollowTail() {
tmpsnake = snake;
ResetBoard(tmpsnake, *food, tmpboard);
//将蛇尾看成食物
tmpboard[tmpsnake.back().Index.x][tmpsnake.back().Index.y] = FOOD;
tmpboard[food->Index.x][food->Index.y] = SNAKE;
Object tail(tmpsnake.back().Index.x, tmpsnake.back().Index.y);
RefreshBoard(tmpsnake, tail, tmpboard);
//还原,排除蛇头与蛇尾紧挨着
tmpboard[tmpsnake.back().Index.x][tmpsnake.back().Index.y] = SNAKE;
return LongestMove(tmpsnake.front().Index, tmpboard);
}
glm::ivec2 Algorithm::FindSafeWay() {
VirtualMove();//虚拟蛇移动吃食物
if (IsTailInside())//检查吃完食物后蛇头与蛇尾之间是否存在路径
return ShortestMove(snake.front().Index, board);
glm::ivec2 move = FollowTail();//没有路径则跟随尾巴
return move;
}
//AI思考
glm::ivec2 Algorithm::AIThinking() {
ResetBoard(snake, *food, board);
glm::ivec2 move;
if (RefreshBoard(snake, *food, board))//可以吃到食物
move = FindSafeWay();//找到一条安全的路
else
move = FollowTail();//不可吃到食物,跟随尾巴
if(move == glm::ivec2(-1,-1))//不能跟随尾巴,任意路径
move = AnyPossibleWay();
return move;
}
void Algorithm::ResetSnakeAndFood() {
snake.clear();
snake.push_back(Object(row / 2 - 1, col / 2 - 1));
snake.push_back(Object(row / 2 - 1, col / 2 + 0));
snake.push_back(Object(row / 2 - 1, col / 2 + 1));
food->Index = NewFood();
win = false;
}
实现展示
动态gif,只录了很小的一部分。整个格子设置得太大了点,27*27。
格子设置太大也导致了后期的情况非常复杂,满屏的难度的比较大,几乎都是差了几个格子然后陷入了死胡同或者陷入了死循环(一直追着蛇尾跑)。嗯,贪吃蛇AI在这里表现可以说是差不多是98%吧。设置得格子比较少的时候,是有满屏的。
格子较少的满屏:
格子较多(27*27)时的最终结果,已经接近大圆满的程度了:
整个工程以及可执行exe可以在github下载:
https://github.com/ZeusYang/Breakout
其中的GreedySnake就是本工程项目目录。