Snake.c 用 C 语言写贪吃蛇!

在学习编程语言的过程中,偶尔写一些字符游戏,既能锻炼编程能力,又可以娱乐自己。今天就让我们一起学习怎样用 C 语言写经典游戏御三家之一的贪吃蛇吧!

当然,为了让程序编写更有条理,我们要采用自顶向下的方法来设计我们的贪吃蛇程序。

这个版本还十分粗糙,更多的打磨就留给大家自己尝试吧!

第一版:会动的蛇

主函数

贪吃蛇的游戏逻辑十分简单:如果游戏没有结束,那就读入一个字符,判断方向,然后让蛇走一步,然后把界面输出让玩家知道现在的游戏状态。根据自顶向下的设计思想,我们先简单地编写主程序框架:

#define UP 0,-1
#define DOWN 0,1
#define LEFT -1,0
#define RIGHT 1,0

int main(int argc, char* argv[]) {
    printGrid();
    while (!gameOver()) {
        char ch = readInput();
        switch (ch) {
            case 'W':
                snakeMove(UP);
                break;
            case 'A':
                snakeMove(LEFT);
                break;
            case 'S':
                snakeMove(DOWN);
                break;
            case 'D':
                snakeMove(RIGHT);
                break;
        }
        printGrid();
    }
    return 0;
}

这样,我们只需一步步实现其中的函数就可以了,就暂时不用操心这段代码了,这就是自顶向下设计的好处:减少了记忆量和思维量。

定义数据结构

在实现函数之前,我们最好先定义怎样存储我们的游戏状态。先不考虑食物,我们将游戏界面分解成几个元素:边界(障碍)、蛇,而蛇又会有蛇头和蛇身的区别。当然,很容易想到一个位置只可能是上面几种元素的一种,所以我们可以直接定义一个二维的字符数组来存放我们的界面。

但是,蛇是动态变化的元素,为了方便,我们最好再定义几个数组,存放蛇和食物的坐标。

有了上面的分析,我们就可以写出我们存放数据的数据结构代码了:

#define SNAKE_MAX_LENGTH 20

#define GRID_WIDTH 12
#define GRID_HEIGHT 12

#define CHAR_GRID_WALL '*'
#define CHAR_GRID_BLANK ' '

#define CHAR_SNAKE_BODY 'X'
#define CHAR_SNAKE_HEAD 'H'

char grid[GRID_HEIGHT][GRID_WIDTH] = {
    "************",
    "*XXXXH     *",
    "*          *",
    "*          *",
    "*          *",
    "*          *",
    "*          *",
    "*          *",
    "*          *",
    "*          *",
    "*          *",
    "************"
};  /* 先直接打表 */

int snakeX[SNAKE_MAX_LENGTH] = {1, 2, 3, 4, 5};
int snakeY[SNAKE_MAX_LENGTH] = {1, 1, 1, 1, 1};
int snakeLength = 5;

实现输出界面函数

首先我们先实现 printGrid() 函数,让我们的程序可以输出界面。这个函数很简单,我们只要一行一行地输出 grid 数组中的字符即可。

/* Clear the screen and print out the grid. */
void printGrid() {
    clearScreen();
    int x, y;
    for (y = 0; y < GRID_HEIGHT; ++y) {
        for (x = 0; x < GRID_WIDTH; ++x) {
            putchar(grid[y][x]);
        }
        putchar('\n');
    }
}

这里我们又用到了一个我们没有实现的函数 clearScreen() ,我们希望每次输出界面的时候都先清空屏幕,经过查资料,在 Linux 系统上,我们只需要调用 system("clear") 就能实现我们的目的。

/* Clear the screen. */
void clearScreen() {
    system("clear");
}

实现输入字符函数

现在,我们已经完成了我们主函数里的第一个函数,接下来要实现的便是 readInput() 函数,让我们能得到用户输入的字符。我们希望只获取到用户输入的第一个字符,其他的全部抛弃,其实现也很简单:

/* Read in only one character and discard the following characters. */
/* If no character is read, return space character. */
char readInput() {
    char ch;
    if (scanf("%c", &ch)) {
        while (getchar() != '\n') continue;
        return ch;
    } else {
        return ' ';
    }
}

让蛇动起来

实现完输出输入函数之后,我们就要开始真正的任务了:让我们的蛇可以动起来,也就是实现 snakeMove(int dx, int dy) 函数。

思路是什么呢?从前面数据结构的定义来看,坐标数组的下标为 snakeLength - 1 的元素就是我们蛇头的坐标,其余都是蛇身的坐标。首先,蛇身部分则依次往前挪一个单位,也就是用第 i 个坐标代替第 i - 1 个坐标(这里,1 <= i <= snakeLength - 1),然后蛇头根据方向进行坐标变换,这样我们的蛇就像是在动起来了。

而移动之前,我们要先让原来的蛇在地图上消失,然后移动之后再画上去,这样就不会出现重复的蛇了。

/* Move the snake one step according to dx and dy */
void snakeMove(int dx, int dy) {
    int i;
    /* Clear the original snake */
    clearSnake();

    /* Propagate the movement first */
    for (i = 0; i < snakeLength - 1; ++i) {
        snakeX[i] = snakeX[i + 1];
        snakeY[i] = snakeY[i + 1];
    }

    /* Set the new head */
    snakeX[snakeLength - 1] = snakeX[snakeLength - 1] + dx;
    snakeY[snakeLength - 1] = snakeY[snakeLength - 1] + dy;

    /* Print the new snake */
    drawSnake();
}

这里我们再次应用自顶向下的方法分解函数,按照思路继续实现 drawSnake()clearSnake() 即可。

/* Clear the snake */
void clearSnake() {
    int i;
    for (i = 0; i < snakeLength; ++i) {
        int x = snakeX[i];
        int y = snakeY[i];
        grid[y][x] = CHAR_GRID_BLANK;
    }
}

/* Draw the snake */
void drawSnake() {
    int i;
    for (i = 0; i < snakeLength; ++i) {
        int x = snakeX[i];
        int y = snakeY[i];
        grid[y][x] = (snakeLength - 1) == i ? CHAR_SNAKE_HEAD : CHAR_SNAKE_BODY;
    }
}

判断游戏是否结束

我们先不编写这部分函数,让游戏永不结束。

int gameOver() {
    return 0;
}

尝试运行

到这里,我们就已经完成了贪吃蛇的一小部分了,至少我们的蛇可以动起来了!让我们编译运行它吧!

gcc snake_move.c -osnake.out
./snake.out

通过输入 WSAD 四个不同的大写字母,然后回车,我们可以看到我们的蛇真的动起来了。

第二版:可以吃东西的蛇

这时候,仅仅是会动的蛇看上去很无聊,没什么意思,这时候,我们希望可以随机地出现一些食物,有了上面会动的蛇做基础,我们只需按部就班,添加关于食物的数据结构和函数即可。

食物的数据结构

和蛇一样,由于食物是动态的,我们最好也跟蛇的实现一样,存放食物的数量和食物的坐标。

#define CHAR_GRID_FOOD '$'

int foodX[FOOD_MAX_NUMBER] = {0};
int foodY[FOOD_MAX_NUMBER] = {0};
int foodNumber = 0;

随机放食物

我们希望食物可以随机出现,所以我们传入一个概率参数。之后便是随机挑选一个空白的格子放食物。

这里采用的策略是每走一步之后就随机放食物。所以我们要在主函数 switch 后添加一句 placeFood(FOOD_PROBABILITY);,然后我们开始实现这个函数。

/* Randomly place food at a blank place according to probability*/
void placeFood(double food_prob) {
    int x, y;

    /* Too much food! */
    if (foodNumber + 1 > FOOD_MAX_NUMBER) return;
    /* Unlucky! */
    if ((double)rand() / RAND_MAX > food_prob) return;

    do {
        x = (int)(GRID_WIDTH * ((double)rand() / RAND_MAX));
        y = (int)(GRID_HEIGHT * ((double)rand() / RAND_MAX));
    } while (grid[y][x] != CHAR_GRID_BLANK);

    foodNumber++;
    foodX[foodNumber - 1] = x;
    foodY[foodNumber - 1] = y;
    drawFood();
}

这里 drawFood() 的实现和 drawSnake() 类似,节省篇幅,我直接放代码了。

/* Draw food */
void drawFood() {
    int i;
    for (i = 0; i < foodNumber; ++i) {
        int y = foodY[i];
        int x = foodX[i];
        grid[y][x] = CHAR_GRID_FOOD;
    }
}

让蛇可以吃到食物

假如蛇头碰到了食物,那么我们就让蛇长一节,直接将食物的位置当成新的蛇头即可,这时就不必再一格格移动蛇身了。所以,我们先修改 snakeMove 函数,将原来移动蛇身的代码加一层 if 判断:

if (!eatFood(dx, dy)) {
    /* Original code for movement */
}

然后我们实现 eatFood 函数:

/* See if our lovely snake has eaten a food */
int eatFood(int dx, int dy) {
    /* Get future head coordinates */
    int headX = snakeX[snakeLength - 1] + dx;
    int headY = snakeY[snakeLength - 1] + dy;

    if (grid[headY][headX] == CHAR_GRID_FOOD) {
        /* Will it be too long? */
        if (snakeLength + 1 > SNAKE_MAX_LENGTH) return 0;

        /* Eat it! */
        snakeLength++;
        snakeX[snakeLength - 1] = headX;
        snakeY[snakeLength - 1] = headY;

        foodNumber--;
        int i;
        for (i = 0; i < foodNumber; ++i) {
            if (headX == foodX[i] && headY == foodY[i]) {
                foodX[i] = foodX[foodNumber];
                foodY[i] = foodY[foodNumber];
                break;
            }
        }
        return 1;
    }

    return 0;
}

判断游戏是否结束

为了简单方便起见,我们用一个全局变量 gameStatus 来存储当前的游戏状态,然后定义一些常量来代表游戏状态。

#define STATUS_NORMAL 0
#define STATUS_GAME_OVER 1

int gameStatus = STATUS_NORMAL;

那么,什么时候会结束呢?无非三种情况:

  1. 蛇跑出了边界
  2. 蛇撞上了障碍物
  3. 蛇撞到了自己

那么,我们在蛇移动一步之前先试探会不会输就可以了。在 snakeMove 之前添加一段代码试探试探:

if (!predictMovable(dx, dy)) { 
    gameStatus = STATUS_GAME_OVER;
    return;
}

然后我们按照刚才的分析,实现 predictMovable 函数:

/* Check if out lovely snake can make its move */
int predictMovable(int dx, int dy) {

    /* Get future head coordinates */
    int headX = snakeX[snakeLength - 1] + dx;
    int headY = snakeY[snakeLength - 1] + dy;
    int i;

    /* 1. Will it run out of bounds? */
    if (headX <= 0 || headX >= GRID_WIDTH - 1 || \
        headY <= 0 || headY >= GRID_HEIGHT - 1) {
        return 0;
    }

    /* 2. Will it hit the bricks? */
    if (grid[headY][headX] == CHAR_GRID_BRICK) {
        return 0;
    }

    /* 3. Will it bump into itself? */
    for (i = 0; i < snakeLength; ++i) {
        int bodyX = snakeX[i];
        int bodyY = snakeY[i];

        if (headX == bodyX && headY == bodyY) {
            return 0;
        }
    }

    /* Yes, it can move! */
    return 1;
}

看上去 1 和 2 似乎是重复的,但如果我们不事先判断我们的蛇会不会超出边界,后面的操作就会发生数组越界错误。

最后修改 gameOver 函数即可:

int gameOver() {
    return gameStatus == STATUS_GAME_OVER;
}

检验成果

再次编译运行,可以看到有食物随机地出现,蛇吃到食物也会长长了!

第三版:不用回车的酷版本!

如果每次输入完方向都要按回车,我们的蛇才能动的话,这样就太不酷了!怎样才能像真正的贪吃蛇那样蛇不停地跑,我们按下键能马上转向不用回车呢?这时候,我们就要用到一些比较高级的函数了,我们参考别人的代码,将我们自己的贪吃蛇代码融合到其中即可:Linux 下非阻塞地检测键盘输入的方法,由于代码较长,就不粘贴在此了,可以参考文末的 GitHub 链接的代码。

再次编译运行,这下玩起来像是真正的贪吃蛇了!

第四版:智能蛇

在这个版本里,我们编写一个简单的函数,让贪吃蛇能够自己找食物、躲障碍!既然是“贪吃”蛇,那我们就写一个简单的贪吃函数好了!

思路也很简单:先判断蛇头四个方向哪个可以走,然后找找看哪个方向离食物最近就好了,如果没有食物,那就保证不死就好了。

int manhattanDist(int x1, int y1, int x2, int y2) {
    return abs(x1 - x2) + abs(y1 - y2);
}

/* Caculate and make an intelligent move */
char nextMove() {
    /* Find a direction that is near one food */
    int i = 0;
    int headX = snakeX[snakeLength - 1];
    int headY = snakeY[snakeLength - 1];
    int distMin = 9999;
    char direction = 'D';
    for (i = 0; i < foodNumber; ++i) {
        int fX = foodX[i];
        int fY = foodY[i];
        int dist = 0;
        if (predictMovable(UP)) {
            dist = manhattanDist(headX, headY - 1, fX, fY);
            if (dist < distMin) {
                distMin = dist;
                direction = 'W';
            }
        }

        if (predictMovable(DOWN)) {
            dist = manhattanDist(headX, headY + 1, fX, fY);
            if (dist < distMin) {
                distMin = dist;
                direction = 'S';
            }
        }

        if (predictMovable(LEFT)) {
            dist = manhattanDist(headX - 1, headY, fX, fY);
            if (dist < distMin) {
                distMin = dist;
                direction = 'A';
            }
        }

        if (predictMovable(RIGHT)) {
            dist = manhattanDist(headX + 1, headY, fX, fY);
            if (dist < distMin) {
                distMin = dist;
                direction = 'D';
            }
        }
    }

    if (distMin == 9999) {
        if (predictMovable(RIGHT)) direction = 'D';
        if (predictMovable(LEFT)) direction = 'A';
        if (predictMovable(UP)) direction = 'W';
        if (predictMovable(DOWN)) direction = 'S';
    }

    return direction;
}

这里为了简单起见,用了曼哈顿距离来表示距离食物的远近。

用这个函数替代原本获取终端输入的函数,再次编译运行,可以看到我们的蛇可以自己动起来,还能自动找食物了,是不是很有趣呢!不过等他长到一定长度的时候,很容易就把自己困死了……

后记

当然,虽然我们实现了看似不少的功能,但是这个版本仍然非常粗糙,我们还可以增加不少的功能,比如:

  1. 提供友好的菜单,供用户选择场地大小、速度等
  2. 提供计分功能
  3. 在场地内设置障碍,使游戏更有挑战性
  4. 改善智能蛇函数,让蛇蛇变得更智能

等等……

可以看到,如果我们使用自顶向下的设计方法来编写程序,我们轻而易举就能写出很不错的程序,所以,想要再添加功能,也不是难事。

附上可以编译的源代码地址吧!(符合 ANSI C 标准):https://github.com/howardlau1999/snake