設計簡單的貪喫蛇AI

前言

接着上一篇設計簡單的貪喫蛇。本博客的目標是寫出一個通過幾率比較大的貪喫蛇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;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章