前言
接着上一篇設計簡單的貪喫蛇。本博客的目標是寫出一個通過幾率比較大的貪喫蛇AI。
代碼重構
由於設計AI什麼的代碼我自己不太會用C寫出較爲易寫的代碼,這篇博客的源代碼採用C++實現,重構了上一篇簡單的貪喫蛇的C代碼。
新重構後的代碼有4個部分:Game
類表示地圖和一小部分邏輯,Snake
類表示蛇和一小部分邏輯,SnakeAI
類表示貪喫蛇AI,剩下的一些函數是關於操作系統兼容的代碼。
Game類
Game類包含地圖和遊戲邏輯。有下列函數:
地圖相關:
is_food_cell
表示是否是食物格子;is_obstacle
表示是否是障礙物;is_snake_cell
表示是否是蛇;is_valid_cell
表示格子是否可以走;get_cell_type
表示獲取格子的類型,是空白還是障礙還是蛇等;set_cell_type
表示修改格子,爲原來的updateMap
函數;output
函數輸出地圖到控制檯;scale
表示地圖面積;width
函數表示地圖寬度;height
函數表示地圖高度。
遊戲邏輯相關:
is_win
表示是否通關;is_game_over
表示是否遊戲結束;score
表示遊戲分數;set_game_over
表示設置遊戲結束;set_score
表示修改遊戲分數。
Snake類
Snake類包含蛇的相關邏輯,有下列函數:
body
表示獲取蛇的所有部分的座標;head
表示獲取蛇頭座標;tail
表示獲取蛇尾座標;length
表示獲取蛇長;move
表示蛇向某個方向移動一格。
SnakeAI類
SnakeAI類是本篇博客要介紹的內容,由下列函數:
build
表示初始化哈密爾頓迴路;build_path
表示通過移動方向序列推導出移動過程各位置座標;decide_next
表示下一步移動的決策;distance
表示兩個點在哈密爾頓迴路中的距離;find_maximum_path
表示尋找地圖中兩點的最長路徑;find_minimum_path
表示尋找地圖中兩點的最短路徑。
其他的一下函數
操作系統相關的函數請參考上一篇博客。
智能蛇
首先實現一個貪喫蛇的AI,我們可以採取什麼樣的方式呢?我們第一想到的就是求出蛇到食物的最短路徑然後直接走過去,如果我們嘗試這麼做的結果就是蛇很快就死了,因爲蛇自己很容易纏在一起然後蛇頭與食物就被分到兩個分量內了,當然遇到這種情況可以遊蕩直到蛇頭和食物間連通。
但是我們觀察了一些網上貪喫蛇AI的gif圖後發現,如果我們讓蛇一直貼着牆(障礙物,蛇身體)走,那麼蛇本身就基本不會圈出好幾片不連通的區域,這樣我們不管食物蛇也會自己走到食物上的。
求最長路
當然這就需要我們寫一個求哈密爾頓迴路(爲什麼請參考下一小節決策)的算法。首先最短路算法很容易實現,一個簡單的廣度優先搜索(BFS)即可實現。當我們有了一條最短路後,我們就可以通過調整最短路來達到最長路。
怎麼個調整呢,比如從(1,5)開始,到(1,1)(座標的第一個數字表示第幾行,第二個數字表示第幾列)的最短路是S,A,A,A,A,A,W
(S表示向下走,A表示向左走,D表示向右走,W表示向上走),那麼第一個S我們就可以調整成D,S,A
,如果S的右邊的格子和右下方的格子都是空白格子的話。如果地圖是6*6的,我們,現在調整後我們先走到右邊界,再走回左邊界,再回到(1,1)。我們再擴展一次,我們發現第一個A(緊跟在D,S
後的那個A
)可以調整成S,A,W
,然後這個新的第一個A又可以調整成S,A,W
,不斷地調整後路徑就變成L型了,還是貼着牆走。然後再擴展我們找到第一個在拐角的W,我們發現可以調整成A,W,D
。。。以此類推,我們發現這樣就可以將最短路徑擴展成一個最長路,具體的實現請參見find_maximum_path
。請讀者在草稿紙上多試幾次模擬上述過程以便理解。
注意到我們做出來的這個路徑加上從(1,1)直接走到(1,5)的路徑就成爲一個環,而且是完全覆蓋整個地圖的,我們知道這樣的環叫做哈密爾頓迴路。當然我們沒有必要從(1,5)開始,我們一開始的路徑可以是(1,1)到(1,1)的。
決策
我們之前求出這樣的最長路,是一個先繞一個方向,後再繞另一個方向繞圈的一個路徑,事實上如果一條蛇沿着這個哈密爾頓迴路一直走下去,那麼遊戲必贏(想想爲什麼?)。好吧其實理解起來也不會很困難吧,蛇身各個格子都一定在哈密爾頓迴路上,因此蛇沿着這條路走,實際上全地圖每個格子都會路過,那麼食物格子必經過,又蛇各個格子都在迴路上,因此蛇一定不會喫到自己。到這裏實際上我們到此整體的思路就結束了。
爲了加快程序,我們可以在最開始蛇比較短的時候走最短路徑加快程序的速度(畢竟沿着哈密爾頓迴路走的速度太慢了,每次喫食物最壞情況就是遍歷完整個圖,蛇步數最壞
源代碼
編譯時請開啓C++11。
原來是寫在好幾個文件裏的,爲了方便大家編譯測試,這裏就把所有代碼合併在一起了。。
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <queue>
#include <climits>
#include <stdexcept>
#include <algorithm>
#if defined(WIN32) || defined(_WIN32)
#include <windows.h>
#include <conio.h>
// 設置光標位置到(x, y)
void setCursor(int x, int y) {
COORD c;
c.X = x;
c.Y = y;
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), c);
}
// 清空屏幕,以便重新打印地圖
void clearScreen() {
system("cls");
}
void sleepProgram(int ms) {
Sleep(ms);
}
char getKey() {
if (_kbhit()) return _getch();
else return -1;
}
#else
#include <termio.h>
#include <unistd.h>
#include <fcntl.h>
// 設置光標位置到(x, y)
void setCursor(int x, int y) {
printf("\033[%d;%dH", y + 1, x + 1);
}
// 清空屏幕,以便重新打印地圖
void clearScreen() {
printf("\033[2J");
}
// Linux下實現Windows的getch函數
int _getch() {
struct termios tm, tm_old;
int fd = 0, ch;
if (tcgetattr(fd, &tm) < 0) {//保存現在的終端設置
return -1;
}
tm_old = tm;
cfmakeraw(&tm);//更改終端設置爲原始模式,該模式下所有的輸入數據以字節爲單位被處理
if (tcsetattr(fd, TCSANOW, &tm) < 0) {//設置上更改之後的設置
return -1;
}
ch = getchar();
if (tcsetattr(fd, TCSANOW, &tm_old) < 0) {//更改設置爲最初的樣子
return -1;
}
return ch;
}
char getKey() {
fcntl(0, F_SETFL, O_NONBLOCK);
return _getch();
}
void sleepProgram(int ms) {
usleep(ms * 1000);
}
#endif
void putCharAt(char newChar, int x, int y) {
setCursor(x, y);
putchar(newChar);
}
// 座標位移, 下標:左0,右1,上2,下3
// 表示從屏幕左上角爲原點,向下爲x軸,向右爲y軸。
// dx 表示方向爲下標i的時候的x軸位移
const int dx[] = { 0, 0, -1, 1 };
// dy 表示方向爲下標i的時候的y軸位移
const int dy[] = { -1, 1, 0, 0 };
const char ESCAPE = 27; // ESC 按鍵的ASCII碼
// 隨機一個[0,n)的數字
int randomN(int n) {
return (int)(rand() * 1.0 / RAND_MAX * n);
}
template<typename T>
T **new_array(int row, int col) {
T **res = new T*[row];
for (int i = 0; i < row; ++i)
res[i] = new T[col];
return res;
}
template<typename T>
void delete_array(T **array, int row, int col) {
for (int i = 0; i < row; ++i)
delete[] array[i];
delete[] array;
}
enum Direction {
WEST = 0, EAST, NORTH, SOUTH
};
Direction negative(Direction d) {
switch (d) {
case WEST: return EAST;
case EAST: return WEST;
case NORTH: return SOUTH;
case SOUTH: return NORTH;
default: throw std::invalid_argument("Unrecognized direction " + d);
}
}
struct Point {
int x, y;
Point(int _x, int _y) : x(_x), y(_y) {}
Point to(Direction d) {
return Point(x + dx[d], y + dy[d]);
}
Point from(Direction d) {
return to(negative(d));
}
Point to(Point dir) {
return Point(x + dir.x, y + dir.y);
}
bool operator==(const Point &b) const {
return x == b.x && y == b.y;
}
bool operator!=(const Point &b) const {
return x != b.x || y != b.y;
}
};
enum MapCell {
FOOD_CELL = '$', // 表示地圖中的食物格子
SNAKE_HEAD = 'H', // 表示地圖中蛇的頭
SNAKE_BODY = 'X', // 表示地圖中蛇的身體
EMPTY_CELL = ' ', // 表示地圖中的空格子
BORDER_CELL = '*' // 表示地圖的邊界
};
class Game {
int _width, _height;
MapCell **map;
std::vector<Point> empty;
Point food; // 食物位置
int gameOver = 0, win = 0, _score = 0; // 遊戲是否結束,是否勝利
public:
Game(int w, int h) : _height(h), _width(w), food(0, 0) {
int i, j;
map = new_array<MapCell>(h + 2, w + 2);
for (i = 0; i <= h + 1; ++i) {
for (j = 0; j <= w + 1; ++j) {
if (i == 0 || j == 0 || j == w + 1 || i == h + 1)
map[i][j] = BORDER_CELL;
else {
map[i][j] = EMPTY_CELL;
// 記錄空格子
empty.push_back(Point(i, j));
}
}
}
}
~Game() {
delete_array(map, _height + 2, _width + 2);
}
int width() { return _width; }
int height() { return _height; }
int scale() { return _width * _height; }
int score() { return _score; }
bool is_game_over() { return gameOver; }
void set_game_over() { gameOver = true; }
bool is_win() { return win; }
// 判斷(x,y)是否是食物格子
bool is_food_cell(const Point &p) {
return p.x == food.x && p.y == food.y;
}
// 判斷(x,y)是否界外
bool is_out_of_bound(const Point &p) {
return p.x < 1 || p.x > _height || p.y < 1 || p.y > _width;
}
bool is_obstacle(const Point &p) {
return map[p.x][p.y] == BORDER_CELL;
}
// 判斷(x,y)是不是蛇
bool is_snake_cell(const Point &p) {
return map[p.x][p.y] == SNAKE_BODY || map[p.x][p.y] == SNAKE_HEAD;
}
bool is_valid_cell(const Point &p) {
return !is_out_of_bound(p) && !is_obstacle(p) && !is_snake_cell(p);
}
// 更新地圖,順便更新屏幕
void set_cell_type(const Point &p, MapCell newChar) {
int x = p.x, y = p.y;
// 維護空格子
// 如果少了一個空格子
if (map[x][y] == EMPTY_CELL && newChar != EMPTY_CELL) {
for (auto it = empty.begin(); it != empty.end(); ++it)
if (*it == p) {
empty.erase(it);
break;
}
}
else if (map[x][y] != EMPTY_CELL && newChar == EMPTY_CELL) {
// 如果多一個空格子
empty.push_back(Point(x, y));
}
map[x][y] = newChar;
// 更新屏幕
putCharAt(newChar, y, x);
}
// 輸出地圖
void output() {
for (int i = 0; i <= _height + 1; ++i) {
for (int j = 0; j <= _width + 1; ++j)
putchar(map[i][j]);
putchar('\n');
}
}
// 在地圖中生成一個食物格子
void generate_food() {
// 不斷地隨機位置直到找到一個空格子
food = empty[randomN(empty.size())];
// 更新地圖的(foodX, foodY)。
set_cell_type(food, FOOD_CELL);
}
Point food_cell() { return food; }
bool set_score(int new_score) {
_score = new_score;
if (_score >= scale()) {
win = true;
return true;
}
return false;
}
MapCell get_cell_type(const Point &p) {
return map[p.x][p.y];
}
};
class Snake {
std::deque<Point> snake;
public:
Game * game;
Snake(Game *g, int initialLength) : game(g) {
if (initialLength > g->width())
throw std::out_of_range("Snake length > map width");
for (int i = 1; i <= initialLength; ++i) {
snake.push_back(Point(1, initialLength - i + 1));
if (i > 1)
game->set_cell_type(snake.back(), SNAKE_BODY);
}
game->set_cell_type(snake.front(), SNAKE_HEAD);
}
void move(Direction dir) {
// 將要到的格子
Point np = snake.front().to(dir);
// 如果不是食物格子
if (!game->is_food_cell(np)) {
// 如果下一步出界或走到了蛇的身體,則遊戲結束
if (!game->is_valid_cell(np)) {
game->set_game_over();
return;
}
// 否則蛇前進一格
game->set_cell_type(snake.back(), EMPTY_CELL);
snake.pop_back();
}
else {
// 如果蛇佔滿了地圖,說明遊戲完成,結束
if (game->set_score(snake.size() + 1)) {
return;
}
else { // 否則繼續生成食物
game->generate_food();
}
}
// 如果蛇不止1格,將原來的H置爲X
if (snake.size() > 1)
game->set_cell_type(snake.front(), SNAKE_BODY);
// 更新蛇的頭
game->set_cell_type(np, SNAKE_HEAD);
snake.push_front(np);
}
int length() { return snake.size(); }
Point head() { return snake.front(); }
Point tail() { return snake.back(); }
const std::deque<Point> &body() { return snake; }
};
class SnakeAI {
Game *game;
Snake *snake;
struct Node {
int idx, dis;
Direction fromDir;
bool vis;
} **nodes;
Direction dir;
std::deque<Direction> find_minimum_path_to(const Point &goal) {
std::deque<Direction> path;
MapCell original = game->get_cell_type(goal);
game->set_cell_type(goal, EMPTY_CELL);
find_minimum_path(snake->head(), goal, path);
game->set_cell_type(goal, original); // restore
return path;
}
std::deque<Direction> find_maximum_path_to(const Point &goal) {
std::deque<Direction> path;
MapCell original = game->get_cell_type(goal);
game->set_cell_type(goal, EMPTY_CELL);
find_maximum_path(snake->head(), goal, path);
game->set_cell_type(goal, original); // restore
return path;
}
int distance(int from, int to, int size) {
return from < to ? to - from : to + size - from;
}
Node &node(const Point &p) { return nodes[p.x][p.y]; }
void find_minimum_path(const Point &src, const Point &dst, std::deque<Direction> &path) {
int row = game->height(), col = game->width();
for (int i = 1; i <= row; ++i)
for (int j = 1; j <= col; ++j)
nodes[i][j].dis = INT_MAX;
path.clear();
node(src).dis = 0;
std::queue<Point> q;
q.push(src);
// bfs
while (!q.empty()) {
Point u = q.front();
q.pop();
if (u == dst) {
build_path(src, dst, path);
break;
}
Direction dirs[] = { EAST, WEST, NORTH, SOUTH };
std::random_shuffle(dirs, dirs + 4);
Direction best = u == src ? dir : node(u).fromDir;
for (int i = 0; i < 4; ++i) {
Point v = u.to(dirs[i]);
if (game->is_valid_cell(v) && best == dirs[i]) {
std::swap(dirs[0], dirs[i]);
break;
}
}
for (int i = 0; i < 4; ++i) {
Point v = u.to(dirs[i]);
if (game->is_valid_cell(v) && node(v).dis == INT_MAX) {
node(v).fromDir = dirs[i];
node(v).dis = node(u).dis + 1;
q.push(v);
}
}
}
}
void find_maximum_path(const Point &from, const Point &to, std::deque<Direction> &path) {
find_minimum_path(from, to, path);
for (int i = 1; i <= game->height(); ++i)
for (int j = 1; j <= game->width(); ++j)
nodes[i][j].vis = false;
Point u = from;
node(u).vis = true;
for (const Direction &d : path) {
u = u.to(d);
node(u).vis = true;
}
for (auto it = path.begin(); it != path.end(); ) {
if (it == path.begin())
u = from;
bool extended = false;
Direction dir = *it, d;
Point v = u.to(dir);
switch (dir) {
case NORTH: case SOUTH: d = WEST; break; // vertical to horizontal
case WEST: case EAST: d = NORTH; break; // horizontal to vertical
}
for (int k = 0; k < 2; ++k, d = negative(d)) { // Try d first, try ~d later.
Point cur = u.to(d), next = v.to(d);
if (game->is_valid_cell(cur) && game->is_valid_cell(next) &&
!node(cur).vis && !node(next).vis) {
node(cur).vis = node(next).vis = true;
it = path.erase(it);
it = path.insert(it, negative(d));
it = path.insert(it, dir);
it = path.insert(it, d);
it = path.begin();
extended = true;
break;
}
}
if (!extended) {
++it; u = v;
}
}
}
void build_path(const Point &from, const Point &to, std::deque<Direction> &path) {
Point now = to;
while (now != from) {
Point parent = now.from(node(now).fromDir);
path.push_front(node(now).fromDir);
now = parent;
}
}
void build() {
std::deque<Direction> maxPath = find_maximum_path_to(snake->tail());
int x = 0;
for (auto it = snake->body().crbegin(); it != snake->body().crend(); ++it)
node(*it).idx = x++;
int size = game->scale();
Point u = snake->head();
for (const Direction &d : maxPath) {
Point v = u.to(d);
node(v).idx = (node(u).idx + 1) % size;
u = v;
}
}
public:
SnakeAI(Game *g, Snake *s) : game(g), snake(s) {
nodes = new_array<Node>(g->height() + 2, g->width() + 2);
build();
}
~SnakeAI() {
delete_array(nodes, game->height() + 2, game->width() + 2);
}
void decide_next() {
if (game->is_game_over())
return;
int size = game->scale();
Point head = snake->head(), tail = snake->tail();
int headIndex = node(head).idx;
int tailIndex = node(tail).idx;
// Try to take shortcuts when the snake is not long enough
if (snake->length() < game->scale() * 3 / 4) {
std::deque<Direction> minPath = find_minimum_path_to(game->food_cell());
if (!minPath.empty()) {
Direction nextDir = minPath.front();
Point nextPos = head.to(nextDir);
int nextIndex = node(nextPos).idx;
int foodIndex = node(game->food_cell()).idx;
headIndex = distance(tailIndex, headIndex, size);
nextIndex = distance(tailIndex, nextIndex, size);
foodIndex = distance(tailIndex, foodIndex, size);
if (nextIndex > headIndex && nextIndex <= foodIndex) {
dir = nextDir;
return;
}
}
}
// Move along the hamilton cycle
headIndex = node(head).idx;
for (Direction d = WEST; d <= SOUTH; d = (Direction)(d + 1)) {
Point adj = head.to(d);
if (!game->is_valid_cell(adj))
continue;
if (node(adj).idx == (headIndex + 1) % size)
dir = d;
}
}
Direction get_direction() {
return dir;
}
};
int main() {
int n, m;
Direction d = WEST;
char ch;
// 初始化隨機種子
srand(time(0));
// 輸入遊戲地圖的規模
do {
std::cout << "Please enter the size of map, height first, width second: ";
std::cin >> n >> m;
if (n < 2 || m < 2 || n > 254 || m > 254) {
std::cout << "Your input is not valid, 5 <= n <= 254, 5 <= m <= 254\n";
}
else {
break;
}
} while (1);
// 初始化地圖、蛇
Game *game = new Game(m, n);
Snake *snake = new Snake(game, 5);
game->generate_food();
SnakeAI *ai = new SnakeAI(game, snake);
// 輸出最開始的地圖
clearScreen();
game->output();
bool autogo = true;
bool enableAI = true;
while (!game->is_game_over()) {
if (autogo) {
if (enableAI) {
sleepProgram(30);
ai->decide_next();
switch (ai->get_direction()) {
case WEST: ch = 'A'; break;
case EAST: ch = 'D'; break;
case NORTH: ch = 'W'; break;
case SOUTH: ch = 'S'; break;
}
}
else {
sleepProgram(500);
char newch = getKey();
if (newch != -1)
ch = newch;
}
}
else
ch = _getch();
if (ch >= 'a' && ch <= 'z') {
ch = ch - 'a' + 'A';
}
// 判斷當前的按鍵
switch (ch) {
// 如果是方向按鍵,記錄方向
case 'A': d = WEST; break;
case 'D': d = EAST; break;
case 'W': d = NORTH; break;
case 'S': d = SOUTH; break;
// 如果是ESC,則退出遊戲。
case ESCAPE: return 0;
// 如果是不正確的按鍵,則跳過重試
default: continue;
}
snake->move(d);
setCursor(0, n + 2);
// 如果遊戲結束且失敗,輸出最終分數
if (game->is_game_over()) {
printf("Game Over! Your final score is %d.\n", game->score());
break;
}
// 如果遊戲結束且勝利
if (game->is_win()) {
printf("Congraulations!\n");
break;
}
}
delete ai;
delete snake;
delete game;
return 0;
}