本文将对如何使用c语言实现一个简单的贪吃蛇游戏进行讲解。主要涉及的内容有:
数组,静态变量,函数指针,动态内存管理,_getch()函数的使用,结构体,数据结构链表
这个项目的代码我放在gitee上,有需要的读者可以去自行拿取:丐版贪吃蛇的实现
正文开始:
简单贪吃蛇的实现
多文件管理
在做项目的时候,多文件管理是非常有必要的。如果将代码全部写在main函数所在源文件,那这个文件中代码可能会有成千上百条。而使用多文件管理,就可以把函数的定义和实现放在专门的文件内,需要使用的时候引用一下对应头文件即可。有些用不到的就可以不进行引用。同时也方便别人使用。所以使用多文件管理是必然的
本项目比较简单,主要的功能实现内容不多,故我分成了三个部分:
1.Game.h
这是个头文件,这个头文件的作用是定义一些贪吃蛇游戏所需要的变量,结构。同时定义函数,包括头文件引用。引用的头文件有:
#include<stdio.h>
#include<stdlib.h>
#include<time.h>
#include<Windows.h>
#include<assert.h>
#include<errno.h>
#include<conio.h>
2.game.c
这是个源文件,在Game.h中会有一些针对于游戏特定功能实现的函数,这些函数的定义将会全部放在这个头文件当中。同时内部还会有一些静态函数。具体的原因后续会提及。
3.test.c
前面两个文件只是对变量的声名和函数的定义等相关内容。我们知道,游戏运行起来是需要测试代码的,所以游戏的测试代码和运行框架都放在test.c文件中,方便调试
游戏规则
真正的贪吃蛇实现是比较多细节的,涉及到图形化编程,获取用户键盘或鼠标点击输入。规则可能也比较复杂。而本项目则是使用c语言加一部分数据结构链表的知识进行编写。需要读者有一些链表知识的基础。对于规则的部分,我在下面将会简单讲述一下:
游戏整体规则:
进入游戏,会简单生成一个蛇头,蛇头我们使用@符号显示。蛇其余身体部分使用*显示。游戏运行的时候,需要根据用户的输入来控制蛇的移动。如果蛇的头撞到了边界,或者撞到自己的身子,游戏就认为是失败的。如果蛇在运动过程中吃到食物$,长度会+1,也就是身体部分多一个 * 。要注意的是:因为这个蛇的运动是根据链表节点坐标插入节点存储蛇的身体值并打印后,清屏后再打印蛇运动后的身体实现的,所以蛇回头的时候正常是先压缩长度,再反方向延伸。但这里只是简单地实现游戏地运行。所以只要蛇头碰到身体 * 就会失败,包括回头操作。
为了趣味性,游戏开始前可以设立一下不同的难度。我简单规定为:
蛇吃到一个食物,长度+1,速度将会加快(这点如何实现后续讲),得分+1。
头文件中设定不同的难度,定义静态变量score=0,速度cls_speed=50:
typedef enum Set_socre {
Low = 50,
Mid = 100,
High = 200
}Set_score;//难度对应的分数
static int score=0;
static int cls_speed=50;
当得分达到难度对应的分数的时候,则视为游戏成功。
上述就是本项目中贪吃蛇游戏的整体规则。
搭建测试代码
游戏的运行离不开测试代码。当然,测试代码有很多种写法,具体看个人习惯和喜好来进行编写。下面我将展示我的代码并进行简单讲解。
测试代码:
#include"Game.h"
int main() {
while (1) {
Menu();
srand((unsigned int)time(NULL));//设置时间戳 方便生成随机数
int choice = 0;
printf("\n请输入选项:\n");
scanf("%d", &choice);
switch (choice) {
case 0:
system("cls");
printf("程序退出!\n");
exit(0);
case 1:
system("cls");
printf("欢迎进入贪吃蛇游戏!\n");
Sleep(1500);
system("cls");
Game();
System_control();
break;
case 2:
system("cls");
Game_Attention();
System_control();
default:
system("cls");
printf("没有该选项 请重新输入!\n");
System_control();
break;
}
}
return 0;
}
1.Menu()函数
Menu函数就是游戏的菜单界面,这个很简单,只需要根据想要的效果进行实现就好了。
void Menu() {
printf("*********************贪吃蛇游戏*********************\n");
printf("****************************************************\n");
printf("*********************0.退出程序*********************\n");
printf("*********************1.进入游戏*********************\n");
printf("*********************2.游戏说明*********************\n");
printf("****************************************************\n");
}
2.使用switch语句对用户的输入进行判断,走向哪个功能
但是有一个缺陷就是如果输入乱码会达不到理想的效果,但这里主要还是了解游戏主体部分代码编写,这里的代码健壮性不作深究。旨在展示功能。
3. 使用时间戳
进行随机数设定是因为贪吃蛇吃的食物是随机生成在地图上的。为了方便我直接在主函数生成时间戳,这样后续的所有过程都可以使用随机数。
4.system_control()函数
我们发现不加入一些特定操作,我们的输入会一直保留在上面,就达不到跳转页面的效果。为此,我加入了清屏函数和暂停函数。运行结束后会先暂停,让用户自主随意输入任意键然后会清空之前的输入和显示,进入下个流程的显示内容。让显示屏更加整洁。
void System_control() {
system("pause");
system("cls");
}
5.Game()函数
这是游戏进程的代码,比较复杂,在这只需知道其作用即可。
6.Game_Attention()函数
功能2中有游戏说明的功能,这个函数就是将游戏的规则和操作打印在屏幕上。
void Game_Attention() {
printf("********************************************************************************\n");
printf("*****游戏为贪吃蛇 玩家需要使用上下左右按键进行蛇的移动 具体对应按键如下所示*****\n");
printf("********************************************************************************\n\n");
printf("******************\n");
printf("*****上: w或↑*****\n");
printf("*****下: s或↓*****\n");
printf("*****左: a或←*****\n");
printf("*****下: s或↓*****\n");
printf("******************\n\n");
printf("进入游戏后 会让用户自行选择难度 玩家可自行选择对应难度进行操作\n");
printf("游戏中蛇每次吃一次食物就会变长一次 并且速度加快\n");
printf("每个难度需要的分数不同 当获得分数与目标分数相同时 视为挑战成功\n");
printf("当蛇头碰到墙或者碰到自己身体的时候 则视为挑战失败\n\n");
printf("建议:移动的时候尽量有间隔的按 这个是卡顿版贪吃蛇 按太快了可能跟不上\n");
}
具体的流程我就不再细说了,可以自行前往编译器中调试一番。可根据个人习惯或规则进行修改,在此我仅展现我的代码。
编写主体代码
上述讲述了如何搭建一个测试框架,这个部分我将主要讲述如何设定蛇的定义和主要流程代码的编写
创建地图和蛇
游戏地图的创建:
游戏地图背后的本质很好理解,就是一个字符数组。将这个数组打印在屏幕上,就可以展现出地图。所以我们可以再头文件Game.h中定义一个数组Map。同时也需要规定这个地图的规格。即数组的行和列。
#define row 25//行
#define col 90//列
char Map[row][col];//地图
这里行和列使用预编译处理是因为简单轻松的修改地图的规格。因为我使用的编译器不支持C99语法,没办法使用变长数组,也就是说数组的规格必须填常量不能是变量。如果随意定义一个常量,若是想要修改,后续所有涉及到数组规格的操作都得修改。会很麻烦,而采用预编译处理只需要修改预编译后的数字。
蛇的定义:
蛇的定义我将分为两部分:
1.蛇的身体部分
typedef struct Snake_Body {//把蛇的身体当作一个节点
char body;
struct Snake_Body* next;
int x;//横坐标
int y;//纵坐标
}Snake_Body;
把蛇的身体当作一系列的节点,,使用动态内存管理开辟空间将蛇的信息存储在堆空间上。之而前后节点的练习就可以使用一个指向蛇身体结构体的指针。通过指针找到下一个身体部分。
1.蛇结构体
//管理蛇链表的,方便
typedef struct Snake {
int num;//蛇头加蛇尾一共几个节点
Snake_Body* head;//指向蛇头
}Snake;
再定义一个结构体表示蛇,这个结构体存储了蛇的身体长度,还有一个指针指向蛇头。通过这个结构体对后续所有的身体节点进行管理。当然不使用也是可以的,看个人习惯。
同时定义一下食物的符号:#define food '$'
至此,所有的需要创建的变量就已经创建完成。
游戏进程代码
游戏进程就是Game()函数,本部分将重点讲解如何编写这部分代码
设立目标分数
上面提到,为了游戏趣味性,可以让用户选择关卡。
我简单地规定难度:
难度 | 要求 |
---|---|
简单 | 达到50分 |
中等 | 达到100分 |
困难 | 达到200分 |
所以进入游戏后,可以先让用户选择难度:
//先让玩家选择难度关卡
int Player_Score = 0;
Set_Difficult(&Player_Score);
system("cls");
switch (Player_Score) {
case Low:
printf("简单难度 目标50分\n");
break;
case Mid:
printf("中等难度 目标100分\n");
break;
case High:
printf("困难难度 目标200分\n");
break;
}
Sleep(1500);
system("cls");
此时的Player_score就是就是需要达到的目标分数。Set_difficult即为设置难度函数:
void Set_Difficult(int* x) {
while (1) {
printf("***1.简单***2.中等***3.困难\n\n");
printf("请输入选项:\n");
int choice = 0;
scanf("%d", &choice);
switch (choice) {
case 1:
*x = Low;
return;
case 2:
*x = Mid;
return;
case 3:
*x = High;
return;
default:
printf("没有该选项 请重新输入\n");
System_control();
break;
}
}
}
同样的,Sleep函数和system函数都是为了页面的简洁和可读性更强而使用的。
生成地图、蛇头、食物(初始化阶段)
进入游戏后,应该生成一个地图,并且随机位置上生成食物。并且要有一个蛇头,方便后续为用户判断蛇的移动方向
生成地图和蛇头:
//进入游戏后,得先初始化地图并打印
init_map(Map, row, col);
Print_map(Map, row, col);
printf("您将进入贪吃蛇游戏!\n\n");
System_control();
//初始化一个蛇的结构体并初始化
Snake s;
init_Snake(&s);
//创建蛇头
int headx = 0;
int heady = 0;
while (1) {
headx = rand() % (row - 2) + 1;
heady = rand() % (col - 2) + 1;
if (Is_Coordinate_Legal(headx, heady, Map)) break;
}
PushBack_Snake_Node(&s, Add_Snake_Node,headx,heady);
1.init_map函数
该函数就是将数组初始化赋值,将数组内部赋值为空地图需要的元素;
void init_map(char map[row][col], int ROW, int COL) {//初始化
//第一行和最后一行是全部'-'
//其余行只有第一列和最后一列是这个'|',剩下的为空格' '
for (int i = 0; i < ROW; i++) {
for (int j = 0; j < COL; j++) {
if (i == 0 || i == ROW - 1) map[i][j] = '-';
else {
if (j == 0 || j == COL - 1) map[i][j] = '|';
else map[i][j] = ' ';
}
}
}
}
2.Print_map函数
将地图初始化完成后就需要打印出数组的样子,让用户大致知道地图的范围。
3.init_Snake()函数
然后需要创建一条蛇,使用该函数对蛇Snake结构体进行初始化。
void init_Snake(Snake* snake) {//初始化蛇结构体
snake->num = 0;
snake->head = NULL;
}
4.创建蛇头
然后需要创建一个蛇头,打印在Map上,展示蛇头起始位置。
但是蛇头的位置需要注意,不能是生成在墙边。所以是需要考虑坐标的合理性的。所以在game.c源文件内声明了一个静态函数:
static int Is_Coordinate_Legal(int x,int y,char Map[row][col]) {//判断坐标是否合法?
if (Map[x][y] == '@' || Map[x][y] == '*' || Map[x][y] == '$') return 0;//不合法
else return 1;//合法
}
使用静态函数的原因是因为这个函数是只需要在Game()函数内部使用,不需要额外对其定义。直接在本文件中定义静态函数即可。
但是创建蛇头为什么判断输入坐标合法性的时候,要判断三种情况呢?
因为不只是蛇头要创建,后面还需要随机找位置创建食物,创建食物的时候是需要在非这三种情况下的时候找位置创建。
5.PushBack_Snake_Node函数
这是一个对链表进行尾插的函数:
void PushBack_Snake_Node(Snake* snake, Snake_Body* (*pushback)(Snake*, char,int,int),int x,int y) {
if (snake->head == NULL) {//
snake->head = pushback(snake, '@',x,y);
snake->num++;
}
else {
Snake_Body* smove = snake->head;
while (smove->next) {
smove = smove->next;
}
smove->next = pushback(snake, '*',x,y);
}
}
这个函数会有两种情况,一种是当传入的Snake结构体指向蛇头的指针为空时,会插入一个蛇头节点。反之走到当前链表最后一个节点,插入蛇身节点。
该函数运用函数指针,指向Add_Snake_Node函数
Snake_Body* Add_Snake_Node(Snake* snake, char body,int x,int y) {//增加一个蛇的节点
Snake_Body* sbody = (Snake_Body*)malloc(sizeof(Snake_Body));
assert(sbody);
sbody->next = NULL;
sbody->body = body;
sbody->x = x;
sbody->y = y;
return sbody;
}
这部分内容需要读者有一些链表的知识。这里不作深入讲解。
创建食物和打印初始化地图:
//现在需要在地图上先随机找个位置生成食物
Create_food(Map);
//打印当前地图上的蛇头,食物状况,给用户接下来选定蛇的运动方向做准备
//还需要把链表上的数据输入地图中
Map[s.head->x][s.head->y] = s.head->body;
Print_map(Map, row, col);
//
Create_food函数
void Create_food(char Map[row][col])
{
while (1) {
int foodX = rand() % (row-2)+1;
int foodY = rand() % (col-2)+1;
if (Is_Coordinate_Legal(foodX, foodY, Map)) {
Map[foodX][foodY] = '$';
break;
}
}
}
然后将蛇头赋值在Map上,将初始化地图打印,效果图如下:
至此,游戏的初始化阶段就完成了。
根据用户输入执行移动
然后这个时候就需要按照游戏说明上写的,通过指定的按键进行蛇的移动。
但是如果我们使用平常使用的scanf、getchar等函数会发现,我们的输入是有回显的,即会显示在显示屏上。但我们最理想的状态肯定是输入一个按键,不回显,然后马上显示蛇的移动效果。所以此时需要使用一个新的函数_getch
这个函数使用的时候需要包含头文件<conio.h>,我先展示接收用户输入的代码:
while (1) {
//1.需要用户主动输入上下左右 或者w s a d进行蛇的移动
//需要使用getch函数,获取键盘扫描码,因为上下左右键没办法用ascii码表示。
//且想要的效果是只从键盘读取,不需要输入到标准输入流上显示
int row_Move = 0;
int col_Move = 0;
while (1) {
//这两个变量是判断蛇头横纵坐标移动的幅度的,因为如果分开几种情况去分别写,代码冗杂,繁琐。
//现在用两个变量获得蛇头移动的位置,直接跳出switch语句,进行复合操作
int key = _getch();
if (key == 0xE0 || key == 0) { // 检测到特殊键
key = _getch(); // 获取扫描码
//_getch()返回值:返回输入的字符的 ASCII 码值。如果是特殊键(如上下左右键),
//则返回 0xE0 或 0,然后需要再次调用 _getch() 获取具体的扫描码。
}
switch (key)
{
case'w':
case'W':
case 72:
row_Move = -1;
col_Move = 0;
goto a;
case's':
case'S':
case 80:
row_Move = 1;
col_Move = 0;
goto a;
case'a':
case'A':
case 75:
row_Move = 0;
col_Move = -1;
goto a;
case'd':
case'D':
case 77:
row_Move = 0;
col_Move = 1;
goto a;
default:
printf("无效键 请重新输入\n");
Sleep(500);
system("cls");
break;
}
}
a:;
注意,case后面的数字不是上下左右键的ascii值,上下左右键是特殊字符,是有特殊的键盘扫描码的。所以需要先使用一次_getch函数判断一下是否接受到了特殊值码(0xE0或0),然后再来接收特殊键的键盘扫描码。
然后会使用一次goto语句,强制跳出循环。因为接收到后就应该执行移动操作,需跳出循环。
这里定义的row_Move变量和col_Move变量是判断当用户输入移动键时,蛇头的横坐标和总纵坐标的移动量。上下左右方向对应的移动量都是不一样的。具体细节放在下一部分讲解。
蛇的移动
判断完用户的输入后,就需要不断的根据输入键执行对应的操作,直到游戏胜利或失败
流程代码:
//当跳出while循环时,说明用户已经输入移动方向,此时需要判断蛇头移动后是否撞墙?撞身子?吃食物?还是没吃到?
int Headx = s.head->x;
int Heady = s.head->y;
if (Map[Headx + row_Move][Heady + col_Move] == '-' || Map[Headx + row_Move][Heady + col_Move] == '|' ||
Map[Headx + row_Move][Heady + col_Move] == '*') {
printf("游戏结束!\n");
return;
}//撞墙撞身子 游戏结束
else if (Map[Headx + row_Move][Heady + col_Move] == '$') {//吃到食物
//吃到食物 速度要加快 还要加分
if(cls_Speed>=6) cls_Speed = cls_Speed - 2;
score++;//需要判断一下分数是否达到目标
if (score == Player_Score) {
printf("游戏胜利!\n");
return;
}
//没有胜利的话 就需要把蛇的长度加大一个*
//需要特别考虑一下的是加入节点的坐标 因为很可能在墙边 也可能周围有身体*
Snake_Body* smove = s.head;
char tmp_map[row][col] = { 0 };//把移动后的蛇存储在这个数组,并且把新生成的食物也打印在里面
init_map(tmp_map, row, col);
int tmpx = smove->x + row_Move;
int tmpy = smove->y + col_Move;
while (smove) {
swap(&tmpx, &(smove->x));
swap(&tmpy, &(smove->y));
tmp_map[smove->x][smove->y] = smove->body;//打印
smove = smove->next;
}//移动 此时smove刚好指向最后一个节点 且此时的tmpx tmpy是移动前那条蛇的最后一个位置
//蛇移动之后,刚好最后一个节点会空出来,这个位置是一定能加入一个新的节点的
PushBack_Snake_Node(&s, Add_Snake_Node, tmpx, tmpy);
tmp_map[tmpx][tmpy] = '*';
//此时蛇已经被打印在tmp_map数组里面了
//生成新的食物
Create_food(tmp_map);
arr_cpy(Map, tmp_map);//最后打印的还是Map数组,赋值回去
Sleep(cls_Speed);
system("cls");
Print_map(Map, row, col);
}
else {//只剩下空格一种可能,那就是什么也没吃到
//什么也没吃到,移动这个蛇
Snake_Body* smove = s.head;
//蛇头根据坐标偏移量row_Move和col_Move进行移动。其他的身体部分移动实际上就是走到前一个节点原来的位置
//因为没吃掉食物 所以食物应该仍停留在原来位置
char only_food[row][col] = { 0 };
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
if (Map[i][j] == '@' || Map[i][j] == '*') {
only_food[i][j] = ' ';
}
else only_food[i][j] = Map[i][j];
}
}
//然后将移动后的蛇输入至这个数组
//先移动蛇
//第一个节点使用偏移量操作
//对于其他节点,会建立一个临时变量记录前一个节点原来的坐标,与之交换即可
//交换后得到新位置,直接将蛇打印在only_food数组上
int tmpx = smove->x + row_Move;
int tmpy = smove->y + col_Move;
while (smove) {
swap(&tmpx, &(smove->x));
swap(&tmpy, &(smove->y));
only_food[smove->x][smove->y] = smove->body;//赋值
smove = smove->next;
}//完成
//最后打印的是Map数组,所以还要赋值回去
arr_cpy(Map, only_food);
Sleep(cls_Speed);
system("cls");
Print_map(Map, row, col);
}
}
}
偏移量的作用:
刚刚我们知道了row_Move和col_Move两个变量是蛇头根据上下左右操作的得到的横纵坐标的偏移量。此时我们应该操作蛇的移动。(我们知道Headx和Heady都是当前蛇头的横纵坐标)
但是蛇不能随便就移动,因为很可能移动后有不同情况:
1.撞墙或者撞到身子
如果蛇移动后的位置是边界或者自身身体,则游戏失败,需退出游戏进程,即执行return操作
2.吃到食物
在这我们得先知道蛇是如何移动的:
假如某一时刻一条蛇的信息存储在数组Map上,当用户输入移动方向时,我们可以先进行清屏操作,即system("cls");
。然后再把蛇移动后的信息存储在Map上。蛇移动的速度就取决于清屏的速度。如果直接清屏,速度是非常快的。所以我们可以在清屏前使用Sleep函数加以限制。表示停留多少毫秒。每吃到一次食物就加快一点,也就是将停留时间减小。
然后再来执行吃食物后的操作:
先进行加分,然后把速度加快(减一给一点限制)。
然后来判断是否达到了目标分数,如果达到了就游戏胜利,退出进程。
现在需要再来理清蛇是如何移动的:
蛇的移动是蛇头先动,后续节点都是跑到它自己前一个节点原来的位置。此时最后一个位置会空出来,刚好就能加入新的身体节点。
后续的代码逻辑大概就是:
我们现在可以定义一个临时的空地图数组,将蛇移动后的信息,新生成的食物存储在这个临时地图tmp_map上,然后再赋值到Map上。经过cls_speed时间的暂停后再清屏打印。
这里的arr_cpy函数就是将第二个参数的数组赋值给第一个参数中。
3.没吃到食物
有了上面的讲解和理解后,这个部分实现就很简单了。
只不过与上面不一样的是,这一次蛇移动后食物还在,所以蛇移动后的信息不能存储在一个空地图上。需要存储在一个只带有食物的地图上。所以定义这个临时地图为only_food。
后续代码逻辑大致是:
然后把移动前的除了蛇以外的信息全部复制给only_food中。然后把移动后的蛇的信息打印在这个数组上,再复制给Map,然后停留cls_speed时间,再清屏打印。
结语
至此,这个代码的逻辑就大致讲完了。感兴趣的读者可以去gitee上复制一下代码跑一下看看。基本的功能还是实现成功的。结尾附上一些效果图:
图1:
图2: