游戏说明

本代码实现了Windows下(使用了Win API)基于c语言的控制台运行的贪吃蛇游戏.

代码开源至:https://github.com/gngtwhh/snake

代码行数: 537行

文件包括:
game.c 游戏运行逻辑支持
main.c 主函数控制
menu.c 菜单选择控制
snake.h 声明头文件
system.c 系统支持相关

细节描述:
实现菜单选择,游戏基本元素,自定义游戏设置,完善用户交互,处理用户错误输入

实现效果

主界面

image-20230908110606510

自定义

image-20230908110640008 image-20230908110652950

游戏开始

image-20230908110721287

结算界面

image-20230908110739259

代码详解

注:代码中一些函数进行了简化和适当的省略,主要为了体现代码的逻辑

游戏数据结构—game.c

使用一个双向链表来存储蛇,使用一个包含一个坐标对的结构体来存储苹果的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
// 以下在snake.h头文件中
#define bool int // 纯c语言,没有bool类型,需要宏定义bool类型
#define false 0 // 将true和false作为对应到1和0的宏
#define true 1
*/


//游戏数据
typedef struct snake {
int x, y;
struct snake *prior, *next;
} snake;//蛇身体的一个结点的类型
snake *head, *tail;//指向蛇头和蛇尾结点的指针

struct APPLE {
int x, y;
} apple;//苹果的坐标数据

int score, pre_x, pre_y, wait = 500;//得分;前一个x,y坐标;等待时长(和游戏难度有关---改变蛇移动的速度)
int HEIGHT = 30;//地图高度
int WIDTH = 30;//地图宽度
int curSnakeLen = 0;//当前的蛇身长度
int maxSnakeLen = 0;//地图能容纳的最大蛇身长度,达到这个长度意味着游戏胜利

游戏控制—main.c

头文件包含

1
2
3
4
#include <stdio.h>
#include <conio.h> // Console Input/Output,定义了通过控制台进行数据输入和数据输出的函数
#include <stdlib.h>
#include "snake.h" // 包含了所有的函数声明,和为了与c++兼容设置的bool宏

代码逻辑

使用一个while(1)循环来重复开始游戏,从而实现一局游戏结束后可以回到主菜单准备下一次游戏

在循环中使用一个char c;配合_getch()来进行菜单选择,同时对于错误输出专门使用一个函数来处理

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main() {
printMenu();//第一次进入循环前先初始化一次菜单
char c;
while (1) {//控制主循环
gotoxy(37, 17);//定位到输入栏
c = _getch();//vs2022要求将getch()更换为_getch()---标准c编译器换回getch()(?)
if (c == '1') {
initGame();//游戏数据初始化
start();//正式开始一局游戏
destoryGameData();//清除游戏数据,释放空间
system("cls");//游戏结束清屏
printMenu();//重新打印菜单

}
else if (c == '2') {
color(7);//将颜色设置回白色
gotoxy(0, 18);
printf("游戏结束!\n");
break;//跳出循环,结束游戏
}
else wrongInput();//输入非法,打印错误信息
}
return 0;
}

菜单选择—menu.c

包含了菜单界面的图形打印,用户错误输入处理,以及游戏初始化的调用接口

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void printMenu(); // 打印菜单,其中利用了gotoxy()函数进行控制台光标的跳转
void initGame() {// 初始化各项数据,这些函数在game.c中定义
system("cls"); // 清屏
setDifficulty(); // 设置游戏难度
printBox(); //打印界面
initSnakeAndApple(); //初始化游戏数据---蛇和苹果的初始状态
}
void wrongInput() {//处理错误的键盘输入---打印报错信息
gotoxy(43, 17);
printf("输入错误!");
Sleep(1000);//停顿一秒后清除信息
gotoxy(43, 17);
printf(" ");
gotoxy(39, 17);
}

系统相关支持—system.c

因为是一定程度上基于Windows的程序(主要是一些优化和完善,还有关键的光标跳转),需要使用一些win API函数

使用_kbhit()来实现检测按键

头文件包含:

1
2
3
4
5
#include <stdio.h>
#include <stdlib.h>
#include <windows.h> // Win API支持
#include <conio.h>
#include "snake.h"

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//涉及到windows的API
void color(int i) {//更改文字颜色
//SetConsoleTextAttribute是API设置控制台窗口字体颜色和背景色的函数
SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), i);
}
//游戏中每次调用gotoxy时的参数都是根据游戏菜单字符位置/当前坐标计算好传递过来的
void gotoxy(int x, int y) {
COORD c;
static HANDLE h;
h = GetStdHandle(STD_OUTPUT_HANDLE);//从标准设备获取句柄
c.X = x;
c.Y = y;
SetConsoleCursorPosition(h, c);
}
int keyboard(int pre) {//键盘输入判断
char c;
int n = pre;
if (_kbhit()) {//检查是否有键盘输入
c = _getch();//如果有,则进行一次读取
if (c == 'w' || c == 'W')
n = 1;
else if (c == 'a' || c == 'A')
n = 2;
else if (c == 's' || c == 'S')
n = 3;
else if (c == 'd' || c == 'D')
n = 4;
else if (c == ' ')
n = 5;
}
rewind(stdin);//fflush(stdin); 刷新缓冲区,在VS2015之后不再起作用(编译成功但无效果)
if ((pre == 1 && n == 3) || (pre == 2 && n == 4) || (pre == 3 && n == 1) || (pre == 4 && n == 2))
return pre;//如果键盘要求蛇180度转向,则转向失败,蛇仍然按照原来的方向前进
return n;//成功转向,返回下一步前进的方向
}

游戏逻辑—game.c

包含了所有的初始化操作,游戏运行逻辑,结算处理

头文件包含:

1
2
3
4
5
6
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <conio.h>
#include <time.h>
#include "snake.h"

关键代码:

游戏主循环—start()函数

逻辑伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void start(){
print_tips(); // 打印提示

while(1){
if(againstTheWall() || againstSelf()){
gameover();//进行游戏结束的处理
return;
}
print_score();

snake_move(); //包含有键盘输入的检测和处理,并在检测到暂停时修改pause_game的值
if(eat_apple()){
snake_growth(); // 蛇长长
if (SnakeLen == maxSnakeLen) {
gamewin();// 霸屏则游戏胜利
return;
}
generate_new_apples(); // 生成新苹果
score++;
}
if(pause_game){
pause_the_game(); // 暂停,直到检测到要求继续的键盘输入
}
Sleep(wait_time); // 休眠一段时间
}
}

游戏初始化

设置游戏难度—setDifficulty()函数

设置了5个难度,分别对应start()中不同的Sleep时间,以此来影响蛇移动的速度

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void setDifficulty(){
int n, difficulties[6] = {0, 1000, 800, 600, 400, 200};//5种游戏难度---对应不同的等待时间
// 界面优化相关代码忽略

// 输入处理
bool flag = 1;
while(flag){
if (scanf("%d", &n) && n > 0 && n < 6)
flag = 0;//输入成功则跳出循环
else {
system("cls");
printf("请输入难度[1~5](按回车键确认):");
printf("输入错误!");
rewind(stdin);
}
}

// 根据输入调整游戏参数
//wait = 1100 - n * 200; // 旧的调整方法
wait = difficulties[n];//设置等待时间

//初始化分数
score = 0;

rewind(stdin);//刷新缓冲区
system("cls");//清屏
}

打印界面—printBox()函数

关键是坐标的计算,要在正确的位置进行打印

使用"□"字符串进行地图的打印,因为该字符占用2字节,所以x坐标每次要+=2而不是+=1

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void printBox(){
set_map_size(); // 设置地图大小,大小包括边界(WIDTH和HEIGHT变量,代码略)

//+=2的原因是方块字符并非ASCII字符,占用两个字节大小---坐标每次需要+2而不是+1
//打印围墙
system("cls");
for (int i = 0; i < WIDTH * 2; i += 2) {//上下
gotoxy(i, 0);
printf("□");
gotoxy(i, HEIGHT - 1);
printf("□");
}
for (int i = 1; i <= HEIGHT - 1; i++) {//左右
gotoxy(0, i);
printf("□");
gotoxy(WIDTH * 2 - 2, i);
printf("□");
}
//打印盒子内部
color(7);
for (int j = 1; j <= HEIGHT - 2; j++) {
for (int i = 2; i < WIDTH * 2 - 2; i += 2) {
gotoxy(i, j);
printf("□");
}
putchar('\n');
}
}

初始化蛇和苹果数据

此处涉及到链表操作!

1.首先容易想到苹果只需要记录其x,y坐标即可

2.然后对于蛇,其行为有2种—向前移动一格而不增长,前进并增长一格

3.而且蛇的每一个结点的运动方向都不一定相同—因为蛇会拐弯

如果使用单链表,就需要对每一个结点保存其下次前进的方向,也就是要知道其前一个结点的坐标是在当前结点的哪个方向(上/下/左/右)并进行存储,不仅浪费空间,而且频繁修改会导致效率低下

所以,这里使用双链表来实现,很容易找到前一个结点的坐标来确定移动方向

实际上这里的双链表实际只影响了当前的蛇尾,因为蛇的每一个节点都是相同的,所以前进仅需在蛇头前进方向新增一个蛇头结点作为新蛇头(代码实现实际上是在蛇头后添加一个蛇身结点),然后删除当前的最后一个结点,即蛇尾

由此为了效率还需使用一个尾指针tail来指向蛇尾(链表尾)

综上所述,我们要存储(初始化)的数据即为:

1.苹果的坐标结构体
2.一个带有头结点(直接作为蛇头)的双向链表
3.一个指向当前链表尾结点(蛇尾)的指针tail

同时该部分还要进行游戏刚开始的苹果(默认在地图左上角),蛇身(默认在苹果右边,初始长度为4)的打印

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void initSnakeAndApple(){
make_a_snake(4); // 创建一条长度为4的蛇---即初始化一个双向链表,并设置尾指针
// 同时要对蛇的每一个节点设置初始坐标

// 设置蛇的长度相关参数
curSnakeLen = 4;//初始时蛇的长度为4
maxSnakeLen = (WIDTH - 2) * (HEIGHT - 2);//根据地图大小计算游戏胜利蛇应该达到的长度

init_apple(8,4); // 初始化苹果

//因为代码实现是先无条件前进再进行吃到苹果的处理,所以蛇尾此时已移动,需要提前记录前一个蛇尾的位置
pre_x = tail->x;
pre_y = tail->y;
}

蛇移动—moveSnake()函数

因为蛇移动并没有长度变化,即蛇头增长,蛇尾缩短,所以直接把蛇尾结点移动到蛇头即可

对应到链表操作即为修改指针指向,将头结点之后的第一个蛇结点修改为蛇尾,蛇尾结点指向第二个蛇结点,然后倒数第二个结点后驱指向NULL

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void moveSnake(int flag) {//蛇的正常前进
int move[4][2] = {
{0, -1},
{-2, 0},
{0, 1},
{2, 0}
};//4种不同的移动方向对应的4种坐标变换
flag--;//对应move数组的元素--从下标0开始
//保存蛇尾位置
pre_x = tail->x;
pre_y = tail->y;
//蛇尾,旧蛇头覆盖打印
gotoxy(tail->x, tail->y);
color(7);
printf("□");

gotoxy(head->x, head->y);
color(6);
printf("■");
//蛇尾断开
snake *temp = tail;
tail = tail->prior;
tail->next = NULL;
//蛇尾结点作为新头,不需要删除创建,节省时间
temp->next = head;
temp->prior = NULL;
head->prior = temp;
head = temp;
//新蛇头位置计算并打印
head->x = head->next->x + move[flag][0];
head->y = head->next->y + move[flag][1];
gotoxy(head->x, head->y);
color(2);
printf("■");
}

蛇长长—snakeGrowth()函数

实际上是双向链表的尾插结点操作

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void snakeGrowth() {//蛇的长度增长
//新增蛇身结点---即在蛇尾新增一个结点并即刻打印(蛇此时已前进一格且吃到苹果
tail->next = (snake *) malloc(sizeof(snake));
tail->next->prior = tail;
tail = tail->next;
tail->next = NULL;
tail->x = pre_x;
tail->y = pre_y;
++curSnakeLen;//当前长度+1

// 打印图像
gotoxy(pre_x, pre_y);
color(6);
printf("■");//进行打印
return;
}

吃到苹果的处理

要进行胜负判断苹果的重新生成和打印

关键代码:

1
2
3
4
5
6
7
8
9
10
// 进行下一个苹果的生成(随机)
srand((unsigned int) time(NULL));
do {
apple.x = ((rand() % (WIDTH - 2)) + 1) * 2;
apple.y = (rand() % (HEIGHT - 2)) + 1;
} while (isOverlap()); // 直到苹果生成在正确的(没有生成在蛇身体上---需要对蛇的链表进行遍历)位置
gotoxy(apple.x, apple.y); // 跳转到该坐标并进行打印苹果
color(4);
printf("■");
score++; // 分数+1

判断坐标是否正确—isOverlap()函数

1
2
3
4
5
6
7
8
9
bool isOverlap() {// 检查新生成的苹果坐标是否和蛇身的任何一个部位重合
snake *temp = head;
while (temp != NULL) {// 遍历蛇身链表
if (apple.x == temp->x && apple.y == temp->y)
return true;
temp = temp->next;
}
return false;
}

胜负判断—是否撞墙或是否撞到自己

是否撞墙—againstTheWall()函数

只需要判断蛇头的坐标是否和边界重合

关键代码:

1
2
3
4
5
6
bool againstTheWall() {//检查撞墙即检查蛇头的坐标是否和墙壁的坐标重合
if (head->x == 0 || head->x == WIDTH * 2 - 2 ||
head->y == 0 || head->y == HEIGHT - 1)
return true;
return false;
}

是否撞到自己—againstTheWall()函数

此时需要遍历整个链表(除了蛇头)来和蛇头的坐标进行比较

关键代码:

1
2
3
4
5
6
7
8
9
bool againstSelf() { // 检查撞到自己即检查蛇头的坐标是否和任一蛇身的坐标重合
snake *temp = head->next;
while (temp != NULL) { // 对链表进行遍历
if (head->x == temp->x && head->y == temp->y)
return true;
temp = temp->next;
}
return false;
}

销毁数据—destoryGameData()函数

每局游戏需要进行数据的销毁(链表)

1
2
3
4
5
6
7
8
9
void destoryGameData() {//主要任务即销毁链表
snake *temp = head;
snake *next = NULL;
while (temp != NULL) {//遍历蛇身链表
next = temp->next;//蛇最短也有4个结点,不存在temp和temp->next为NULL的情况
free(temp);
temp = next;
}
}