前言
接着上一篇设计简单的贪吃蛇。本博客的目标是写出一个通过几率比较大的贪吃蛇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)的。
决策
我们之前求出这样的最长路,是一个先绕一个方向,后再绕另一个方向绕圈的一个路径,事实上如果一条蛇沿着这个哈密尔顿回路一直走下去,那么游戏必赢(想想为什么?)。好吧其实理解起来也不会很困难吧,蛇身各个格子都一定在哈密尔顿回路上,因此蛇沿着这条路走,实际上全地图每个格子都会路过,那么食物格子必经过,又蛇各个格子都在回路上,因此蛇一定不会吃到自己。到这里实际上我们到此整体的思路就结束了。
为了加快程序,我们可以在最开始蛇比较短的时候走最短路径加快程序的速度(毕竟沿着哈密尔顿回路走的速度太慢了,每次吃食物最坏情况就是遍历完整个图,蛇步数最坏 O((nm)2) )。
源代码
编译时请开启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;
}