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就是本工程項目目錄。