设计要求
设计一个简单的贪吃蛇,支持按一次键移动一格或者是按一次键修改蛇的运动方向并不断地运动,允许吃到食物后蛇长+1,碰到障碍物或者是蛇身游戏结束。
实现
程序主干
首先程序主干很明显:是一个循环。包含判断按键来决定蛇的运动方向:
int main() {
// ...
while (!gameOver) {
char ch = getKey(); // 获取用户按下的按键
switch (ch) {
case 'a': move(DIR_LEFT); break; // 蛇向左移动
case 'w': move(DIR_UP); break; // 蛇向右移动
case 's': move(DIR_DOWN); break; // 蛇向下移动
case 'd': move(DIR_RIGHT); break; // 蛇向右移动
case KEY_ESCAPE: exit_program; break; // 游戏手动结束
default: continue; // 输入错误,重新获取输入
}
update_screen(); // 更新屏幕
}
// ...
}
应该没啥好解释的。。允许esc键退出程序。
绘制地图
绘制地图可以是事先导入地图,也可以生成空白地图,本程序生成空白地图,周围一圈障碍表示边界,中间都是可以行走和生成食物的。然后还要生成最开始的蛇,本程序选择将蛇放在第一行左侧。为了自定义程序,我们在游戏开始前还可以要求用户输入地图的规模和蛇的初始长度(为了省事,实现的程序是蛇初始长度不超过地图边长)。蛇的头是’H’,蛇的身体是’X’,地图边界是’*’,食物格子是’$’。
蛇的移动
首先实现蛇的移动,我们需要几个函数,分别是isOutOfBound
判断是否出界,isSnakeCell
判断是否是蛇本身,isFoodCell
判断是否是食物格子。
然后我们考虑蛇移动一格的情况。我们发现,蛇移动一格,相当于多出来头移动到的一格,少掉尾巴的一格(当然可以有其他的想法,不过这么做应该是最方便实现的)。当然我们还需要维护一个序列(数组)表示蛇身子各个格子的坐标,这样我们才可以知道哪里是蛇的尾巴和头。然后注意到原来蛇的头要恢复成’X’(前提是蛇的长度长于2)。然后维护一下地图就好啦。
生成食物
生成食物挺简单的,一般的做法是每次随机一个坐标
(x,y)
,然后判断map[x][y]
是否是空的格子,如果是,那么这就可以放食物,否则重新随机生成
(x,y)
,直到可以放为止。这么写是最简单的,大部分情况下都不会有大问题(毕竟蛇超过地图格子数一半基本就很容易GameOver)
当然万一出现了空白格子比例特别小的情况(比如1个空白格子,剩下的都是蛇的身体),那么这套方案就容易陷入很多次的循环,导致程序运行缓慢。我们可以考虑维护一个序列,存放地图的空白格子,如果少了个格子就从序列中删去,否则加入序列。我们需要每次修改地图的时候都维护一下,因此之前我们的实现可能是map[x][y] = newchar;
,我们修改成updateMap(int x, int y, char newchar)
,在updateMap
函数中维护序列即可,同时这样我们也加强了代码的可维护性,我们如果要监听地图的修改情况,只需要简单地修改一下updateMap
函数即可。最后我们只需要从空白格子的序列中随机一个格子放食物即可,这样就可以避免空白格子比例太小导致循环过多次的问题。(当然也加大的程序实现的复杂度)
吃食物
既然我们生成了食物,我们要需要蛇吃食物。注意到如果蛇吃了食物,相当于尾巴的一格不删去,头多一个(和蛇整体向前移动一格,尾巴多出来效果一样)。注意到这一点我们的代码就好写多了。然后再重新生成食物即可。同时我们还可以判断是否游戏胜利,因为游戏胜利只会在蛇吃到食物的瞬间发生(最后一格的食物被吃到)。
不再闪烁的屏幕
实现完程序后,我发现每次蛇移动后重新生成地图,刷新控制台并重新输出地图会造成屏幕的闪烁(这可能是因为控制台更新屏幕的延迟较高?速度较慢?),这对用户是及不友好的,虽然每次重新输出地图代码十分地好写,但我们为了用户着想,我们应该改成每次只更新屏幕上需要更新的地方(即更新地图变化的位置),参考updateMap
函数。经过资料查阅,我们可以知道在Windows下的修改光标位置的方法,请参见程序的setCursor
函数,调用了Windows API:SetConsoleCursorPosition
。
蛇的自动前进
至此我们实现了按一次按键蛇动一次的程序,但是我们见过的一般的贪吃蛇游戏都是控制蛇的运动方向,而蛇自行定时按照方向移动一格。为了实现这个程序,我们需要让程序每500毫秒才响应一次键盘按键。经过查阅,知道了暂停程序的函数为Sleep(milliseconds)
,请参见程序中的两个sleepProgram
函数。然后我们每次循环都暂停500ms,然后再不阻塞地获取键盘按键(原来实现的原理是getch函数会阻塞程序运行,也就是如果我们不按下键盘按键,getch函数就会一直运行,当然实现的结果与while(if not any key pressed) wait for a short while;
这种类似,当然系统有更好的实现方法减轻CPU的负担)。这样我们就可以做到蛇的自动前进。
同时兼容Linux系统
我们之前的程序都是面向Windows系统的,我们调用了Windows系统才有的windows.h
和conio.h
两个库中的函数,如果我们要实现兼容Linux系统,我们必须写两套系统相关的代码。为此我们提炼出的setCursor
、clearScreen
、getch
、sleepProgram
、getKey
函数都需要编写面向Linux系统的版本,具体代码请参考程序。相关信息请网络搜索。
源代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#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
// 坐标位移, 下标:左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 SNAKE_HEAD = 'H'; // 表示地图中蛇的头
const char SNAKE_BODY = 'X'; // 表示地图中蛇的身体
const char EMPTY_CELL = ' '; // 表示地图中的空格子
const char FOOD_CELL = '$'; // 表示地图中的食物格子
const char BORDER_CELL = '*'; // 表示地图的边界
#define ESCAPE 27 // ESC 按键的ASCII码
#define MAP_SIZE 256 // 表示地图的最大边长 - 2
#define SNAKE_MAX_LENGTH (MAP_SIZE * MAP_SIZE) // 表示蛇的最长可能长度
int gameOver = 0, win = 0; // 游戏是否结束,是否胜利
int map[MAP_SIZE][MAP_SIZE]; // 地图
int snakeX[SNAKE_MAX_LENGTH], snakeY[SNAKE_MAX_LENGTH]; // 蛇身体各个格子的坐标
int emptyX[SNAKE_MAX_LENGTH], emptyY[SNAKE_MAX_LENGTH]; // 空格子的坐标
int snakeLength, emptys; // 蛇的长度, 空白格的数目
int foodX, foodY; // 食物位置
int n, m; // 地图规模
// 输出地图
void outputMap() {
for (int i = 0; i <= n + 1; ++i) {
for (int j = 0; j <= m + 1; ++j)
putchar(map[i][j]);
putchar('\n');
}
}
// 删除数组的第index项元素,并将后面的元素向前移动一格
void removeIndex(int *array, int length, int index) {
for (int i = index; i < length - 1; ++i)
array[i] = array[i + 1];
}
// 判断(x,y)是否是食物格子
int isFoodCell(int x, int y) {
return x == foodX && y == foodY;
}
// 随机一个[0,n)的数字
int randomN(int n) {
return (int) (rand() * 1.0 / RAND_MAX * n);
}
// 判断(x,y)是否界外
int isOutOfBound(int x, int y) {
return x < 1 || x > n || y < 1 || y > m;
}
// 判断(x,y)是不是障碍
int isObstacle(int x, int y) {
return map[x][y] == BORDER_CELL;
}
// 判断(x,y)是不是蛇
int isSnakeCell(int x, int y) {
return map[x][y] == SNAKE_BODY || map[x][y] == SNAKE_HEAD;
}
// 更新地图,顺便更新屏幕
void updateMap(int x, int y, char newChar) {
// 维护空格子序列
// 如果少了一个空格子
if (map[x][y] == EMPTY_CELL && newChar != EMPTY_CELL) {
for (int i = 0; i < emptys; ++i)
if (emptyX[i] == x && emptyY[i] == y) {
removeIndex(emptyX, emptys, i);
removeIndex(emptyY, emptys, i);
--emptys;
}
} else if (map[x][y] != EMPTY_CELL && newChar == EMPTY_CELL) {
// 如果多一个空格子
emptyX[emptys] = x;
emptyY[emptys] = y;
++emptys;
}
map[x][y] = newChar;
// 更新屏幕
setCursor(y, x);
putchar(newChar);
}
// 初始化地图
void initMap() {
for (int i = 0; i <= n + 1; ++i) {
for (int j = 0; j <= m + 1; ++j) {
if (i == 0 || j == 0 || j == m + 1 || i == n + 1)
map[i][j] = BORDER_CELL;
else {
map[i][j] = EMPTY_CELL;
// 记录空格子
emptyX[emptys] = i;
emptyY[emptys] = j;
++emptys;
}
}
}
}
// 在地图中生成一个食物格子
int generateFood() {
// 不断地随机位置直到找到一个空格子
int i = randomN(emptys);
foodX = emptyX[i];
foodY = emptyY[i];
// 更新地图的(foodX, foodY)。
updateMap(foodX, foodY, FOOD_CELL);
}
// 初始化一个长度为snakeLength的蛇
void initSnake() {
snakeLength = 5;
for (int i = 1; i <= snakeLength; ++i) {
snakeX[i - 1] = 1;
snakeY[i - 1] = snakeLength - i + 1;
updateMap(1, i, SNAKE_BODY);
}
updateMap(1, snakeLength, SNAKE_HEAD);
}
// 蛇向dir方向移动1格
void move(int dir) {
// 将要到的格子
int nx = snakeX[0] + dx[dir];
int ny = snakeY[0] + dy[dir];
// 如果不是食物格子
if (!isFoodCell(nx, ny)) {
// 如果下一步出界或走到了蛇的身体,则游戏结束
if (isOutOfBound(nx, ny) || isObstacle(nx, ny) || isSnakeCell(nx, ny)) {
gameOver = 1;
return;
}
// 否则蛇前进一格
updateMap(snakeX[snakeLength - 1], snakeY[snakeLength - 1], EMPTY_CELL);
} else {
// 蛇长度+1
++snakeLength;
// 如果蛇占满了地图,说明游戏完成,结束
if (snakeLength >= n * m) {
win = 1;
return;
} else { // 否则继续生成食物
generateFood();
}
}
// 如果蛇不止1格,将原来的H置为X
if (snakeLength > 1)
updateMap(snakeX[0], snakeY[0], SNAKE_BODY);
// 更新蛇的头
updateMap(nx, ny, SNAKE_HEAD);
// 更新蛇各个格子的位置
for (int i = snakeLength - 1; i; --i) {
snakeX[i] = snakeX[i - 1];
snakeY[i] = snakeY[i - 1];
}
snakeX[0] = nx;
snakeY[0] = ny;
}
int main() {
int d = -1;
char ch;
// 初始化随机种子
srand(time(0));
// 输入游戏地图的规模
do {
printf("Please enter the size of map, height first, width second: ");
scanf("%d%d", &n, &m);
if (n < 2 || m < 2 || n > 254 || m > 254) {
printf("Your input is not valid, 5 <= n <= 254, 5 <= m <= 254\n");
} else {
break;
}
} while (1);
// 初始化地图、蛇
initMap();
initSnake();
generateFood();
// 输出最开始的地图
clearScreen();
outputMap();
// 通过设置autogo来实现是按一次键移动一格还是按一次键修改移动方向
bool autogo = false;
while (!gameOver) {
if (autogo) {
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 = 0; break;
case 'D': d = 1; break;
case 'W': d = 2; break;
case 'S': d = 3; break;
// 如果是ESC,则退出游戏。
case ESCAPE: return 0;
// 如果是不正确的按键,则跳过重试
default: continue;
}
move(d);
setCursor(0, n + 2);
// 如果游戏结束且失败,输出最终分数
if (gameOver) {
printf("Game Over! Your final score is %d.\n", snakeLength - 1);
break;
}
// 如果游戏结束且胜利
if (win) {
printf("Congraulations!\n");
break;
}
}
return 0;
}