[OpenGL & 人工智能] OpenGL开发2D游戏 & 贪吃蛇智能自动寻食

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就是本工程项目目录。

参考博客:http://blog.renren.com/share/265863809/15651358712/2

  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
作者对游戏的说明: 首先,您应当以一种批判的眼光来看待本程序。这个游戏是我制作 的第一部RPG游戏,无任何经验可谈,完全按照自己对游戏的理解进 行设计的。当我参照了《圣剑英雄2》的源码之后,才体会到专业游 戏引擎的博大精深。 该程序的内核大约有2000余行,能够处理人物的行走、对话、战斗, 等等。由于该程序的结构并不适于这种规模的程序,故不推荐您详 细研究该程序。所附地图编辑器的源程序我已经添加了详细的注释, 其程序结构也比较合理,可以作为初学VC的例子。 该程序在VC的程序向导所生成的SDI框架的基础上修改而成。它没有 使用任何关于VC底层的东西。程序的绝大部分都是在CgameView类中 制作的,只有修改窗口特征的一段代码在CMainFrm类中。其他的类 统统没有用到。另外添加的一个类是CEnemy类。 整个游戏的故事情节分成8段,分别由Para1.h ~ Para8.h八个文件 实现。由于程序仅仅能够被动的处理各种各样的消息,所以情节的 实现也只能根据系统的一些参数来判断当前应当做什么。在程序中 使用了冗长的if……else if……结构来实现这种判断。 当然,在我的记录本上,详细的记录了每个事件的判断条件。这种 笨拙的设计当然是不可取的。成都金点所作《圣剑英雄II》采用了 剧本解读的方式,这才是正统的做法。但这也需要更多的编程经验 和熟练的code功夫。 下面列举的是程序编制过程中总结出来的经验和教训。 第一,对话方式应该采用《圣剑英雄II》的剧本方式。 现在的方式把一个段落中所有的对话都混在一个文件中,然后给每 句话一个号码相对应。这样做虽然降低了引擎的难度,却导致剧情的 编写极其繁琐。 第二,运动和显示应当完全分开。 现在的程序中,运动和显示是完全同步的。即:在定时器中调用所有 敌人的运动函数,然后将主角的动画向前推一帧,接着绘制地图,调 用所有敌人的显示函数、重绘主角。这样的好处是不会掉帧,但带来 的问题是,如果要提高敌人的运动速度,那么帧数也跟着上去了。所 以当DEMO版反馈说速度太慢的时候,我修改起来非常困难。而这个问 题到最后也仅仅是将4步一格该成了2步一格。 第三,VC中数组存在上限。如果用“int aaa[1000000000]”定义一个 数组,编译器肯定不会给分配那么大的内存空间。而在这个程序中, 地图矩阵、NPC矩阵都超过了VC中数组的上限。但这一点知道的太晚了。 在1.0版本中已经发现地图最右端缺少了几行,但不知道是什么原因 造成的。(地图编辑器中未出现此问题,因为地图编辑器是用“序列 化”的方式存盘读盘的。)解决这个问题的方法是用“new”来分配 内存空间。 第四,由于不知道应该如何使用“new”和“delete”,几乎所有的DC 都使用了全局变量。这是完全没有必要的。程序运行期大约会耗用20 多M的内存空间,相当于一个大型游戏所使用的内存空间了。 另外,在游戏的剧情、美工方面也有许多问题,总之一个词“业余”。 我就不总结了。下一部作品,我将争取在程序上有一个质的飞跃。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值