在学习编程语言的过程中,偶尔写一些字符游戏,既能锻炼编程能力,又可以娱乐自己。今天就让我们一起学习怎样用 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;
那么,什么时候会结束呢?无非三种情况:
- 蛇跑出了边界
- 蛇撞上了障碍物
- 蛇撞到了自己
那么,我们在蛇移动一步之前先试探会不会输就可以了。在 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;
}
这里为了简单起见,用了曼哈顿距离来表示距离食物的远近。
用这个函数替代原本获取终端输入的函数,再次编译运行,可以看到我们的蛇可以自己动起来,还能自动找食物了,是不是很有趣呢!不过等他长到一定长度的时候,很容易就把自己困死了……
后记
当然,虽然我们实现了看似不少的功能,但是这个版本仍然非常粗糙,我们还可以增加不少的功能,比如:
- 提供友好的菜单,供用户选择场地大小、速度等
- 提供计分功能
- 在场地内设置障碍,使游戏更有挑战性
- 改善智能蛇函数,让蛇蛇变得更智能
等等……
可以看到,如果我们使用自顶向下的设计方法来编写程序,我们轻而易举就能写出很不错的程序,所以,想要再添加功能,也不是难事。
附上可以编译的源代码地址吧!(符合 ANSI C 标准):https://github.com/howardlau1999/snake