目录
前言:
今天来给大家讲一下从零开始写项目经常会遇见的问题,就是你明明学了学校老师讲的知识,会做了那些题,但一当自己想要独立写个小项目时却无从下手的情况。
这是因为平时的项目经验是在太少了,刷题的思维逻辑也并没有转换过来。
有些同学尝试看b站的一下教程来写游戏,但写到一半却发现很多库函数,头文件都是自己没学到的,就误以为自己还没学完,没资格写项目,其实这就走进了误区。我们要从练中学,而不是学了之后再练!
本篇文章将会涉及到Windows API的部分使用,纯C语言的代码,也会给介绍学会使用一个栈结构类似的思维顺序图,来帮助大家知道自己应该怎么做,按什么顺序去实现什么功能,函数,并尽可能让大家看懂代码。
有不会的可以在评论区提出!!
一、贪吃蛇游戏的构思
当我们把C语言的基础学完之后,在把链表学完,差不多就可以尝试做贪吃蛇了。
我们首先要想一下,怎么实现贪吃蛇游戏的基础逻辑。
我们学了链表,可以试着把链表想象成贪吃蛇,地图就是一个长方形或者正方形。
那么我们如何把链表的逻辑转化为贪吃蛇呢?又如何知道蛇的身体应该打印在地图的哪个位置呢?
我们知道链表中可以存储任意类型的数据,对于我们这个二维的贪吃蛇游戏,我们可以将贪吃蛇的身体数据存储在链表的每个节点中,比如横坐标x,竖坐标y。
形如:
typedef struct Snakenode//对贪吃蛇躯体的维护
{
int x;
int y;
struct Snakenode* next;//指向下个节点躯体
}Snakenode, * psnakenode;
我们就创建了一个叫做Snakenode的节点结构体,用来保存位置信息。
那么还有什么数据需要我们用结构体来维护呢?
我们想一下,诶,这个贪吃蛇的头指针我们是不是需要一直知道啊,贪吃蛇游戏的食物的坐标,贪吃蛇的运动方向,游戏的总分,游戏的运行状态:贪吃蛇存活,贪吃蛇死亡(撞墙或者吃到自己)我们是不是也需要知道啊!对于贪吃蛇要吃的食物,我们也可以用节点来存储食物的坐标,当我们的贪吃蛇的头指针与食物坐标重合,就相当于蛇吃到了食物,就需要长长了,这个长长身体的过程其实就是对原本的贪吃蛇链表进行一个插入操作。
对于运行状态和蛇移动方向,由于游戏同时间只会存在一种,所以我们可以用枚举体来维护:
enum game_status //游戏运行状态
{
OK = 1,
ESC,//退出
KILL_BY_WALL,//被墙杀死
KILL_BY_SELF//被自己杀死
};
enum Dir_move//蛇的移动方向
{
right,
left,
up,
down
};
但如果把这几个要维护的数据都存入上面的Snakenode结构体,未免有点太过重复且复杂。
对于这种全局的变量,我们可以新设一个专门的结构体来维护数据:
typedef struct Snake//对贪吃蛇游戏的维护
{
psnakenode psnake;//维护蛇的指针
psnakenode pfood;//蛇的食物也是蛇身体的节点,用一个指针找到这个食物
int foodweight;//此时食物的分值
int Score;//分数
int speed;//蛇的速度,也就是休眠的时间,时间越短,速度越快
enum game_status status;//游戏运行的状态
enum Dir_move dir;//蛇移动的方向
}Snake, * psnake;
要想把我们的这个贪吃蛇游戏维护好,只需要通过调用函数时传入一个snake对象对他自己的数据进行维护就行。对于蛇的速度,食物分值等参数,一开始没想到也不需要担心,你就当做实现一个固定速度移动、食物分数固定的贪吃蛇游戏(我的设定里,蛇移动越快,难度越高,事物的对应分值也就越高),随后在此基础上进行扩充改编。
好,简单构思就到这里,接下来,我们先创建一个main.c的文件,表示这是我们程序的主函数所在文件,再新建一个Snake.h的头文件,Snake.c的c文件,来实现相关的函数。
随后,我们将以上代码全部写入Snake.h中
二、主函数的构建
#include"Snake.h"
int main()
{
test();
return 0;
}
这块代码很简单,就是把Snake.h的头文件声明,然后再主函数中使用了一个test函数(我们还没实现),表示我们游戏的运行主要会在这个test函数里运行。我们在main.c里定义一个test函数,在这个test函数里,我们主要想实现贪吃蛇结构体Snake的创建,游戏开始前的准备,游戏进行中,以及游戏结束后的善后工作:
void test()
{
//创建贪吃蛇
Snake snake = { 0 };//定义一个贪吃蛇游戏结构体对象
Gamestart(&snake);//完成游戏开始前的初始化
Gamerun(&snake);//游戏的运行
Gameend(&snake);//游戏结束后的善后工作
}
我们在调用其他函数前创建了一个Snake结构体类型的snake对象,这个snake对象里装着游戏运行状态,蛇速度,食物分值等游戏所需的必要变量。
有些同学会疑问为什么我们在调用这三个函数时,函数参数会传入一个snake结构体的地址。
这是因为我们的游戏运行变量都存储在snake结构体中,但我们在test创建的是一个局部变量,如果想要在其他函数中对snake进行修改,就必须传入snake的地址,然后在函数调用时用snake类型的指针,也就是psnake接收。
在test函数内我们产生了三个新函数的需求,想要使得test函数完整,我们就必须实现这三个功能的函数。这就是写项目的必要思维,不断根据老的需求产生新的需求,通过一个一个的函数来实现这些功能。
那么剩下的函数我们都可以在Snake.h中声明,在Snake.c中实现函数的定义了。
//Snake.h
//紧跟Snake结构体
void Gamestart(psnake ps);//完成游戏开始前的初始化
void Gamerun(psnake ps);//游戏的运行
void Gameend(psnake ps);//游戏结束后的善后工作
三、Gamestart函数的实现
我们接下来来实现一下Gamestart函数,实现要想一下在游戏开始前我们需要准备哪些工作?
进入游戏时是不是应该出现欢迎界面?基础的游戏屏幕是不是应该适配?初始贪吃蛇与食物的信息是不是应该被创建?(游戏的参数数据我们后面会再加上去调整)。
由于我们是通过控制台来实现游戏,每个人的控制台大小不一样,这样是否会带来一下定位上的干扰?
这里我们可以使用system函数来解决:system()
是C语言标准库中的一个函数,用于执行操作系统命令。它定义在<stdlib.h>
头文件中。它会调用操作系统的命令解释器来执行指定的命令。
譬如:
system("mode con cols=120 lines=35");
system("title 贪吃蛇");
就会将我们运行程序时产生的控制台的大小宽度控制,并且将控制台的名字改为贪吃蛇。
根据这些想法吗,我们又可以在Gamestart函数中新增:
void Gamestart(psnake ps)//完成游戏开始前的初始化
{
system("mode con cols=120 lines=35");//记得在Snake.h中声明头文件
system("title 贪吃蛇");
//打印欢迎信息
Welcome();
//绘制地图
Mapinit();
//初始化蛇
snakeinit(ps);//初始化蛇时应该也会修改snake变量的数据,所以需要传入ps指针,下面同理
//初始化食物
foodinit(ps);
}
那么我们继续实现一下打印欢迎信息的函数Welcome,养成好习惯在Snake.h中添加Welcome函数的声明,在Snake.c中添加Welcome函数的定义。(后面的函数都默认这样)
那么这里我们就会遇见一个疑问了,我们平时的光标都是在最前面(我们键盘的输入位置就是光标显示的位置),在控制台中不能用鼠标控制光标位置,那我们要如何在自己想要的地方打印这些欢迎信息,或者图标,符号呢?
这里我们就需要查资料了(不要抗拒,新手不会是很正常的),Windows API中有专门的接口来实现这些功能:SetConsoleCursorPosition。
不会使用的时候我们也可以问AI,让他给一个简单的使用例子。
这里的HANDLE,GetStadHandle,COORD等大家可以不用了解(很有必要,因为你现在不会的地方多了去了,牢记自己目前的目的是写一个简单的项目,这些我们只需要掌握函数的使用方法就行了,不必太过深究)
我们目前需要实现的函数比较多,为了方便记忆,大家可以类比栈的思维存入表格内:
我们可以大致明白,AI给的这么多代码步骤的目的主要是为了实现光标的移动,我们肯定会在后面大量使用这些代码,所以我们完全可以写一个SetPos函数来复用这个功能。
SetPos的实现
关于SetPos函数的实现,为了让光标到我们想要的地点,所以我们应该给它传入x,y两个参数。
注意,我们在.c文件中的函数参数为 int x,int y,但是在.h中声明时,我们也要告诉编译器这个函数有参数,但是我们不知道参数的名字,所以可以这样声明:
void SetPos(int, int);//设置光标位置
随后就是把以上代码写入SetPos:
void SetPos(int x, int y)
{
//获得设备句柄
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
//根据获得的句柄来设置信息
COORD pos = { x,y };//光标将要到达的坐标
SetConsoleCursorPosition(handle, pos);//调用函数,这些陌生函数记住作用,死记硬背即可
}
随后我们就完成了SetPos函数,根据栈的思维把它弹出我们的函数实现顺序表格:
这样方便大家知道自己接下来应该做什么。
Welcome的实现
那么接下来我们继续来实现Welcome,我们需要找到一个合适的位置来打印我们的信息,大家可以自己调整x,y的值。这些信息应该包括游戏的基础玩法,移动键的操作,欢迎信息以及开始游戏的方式等提示。
譬如:
void Welcome()
{
SetPos(50, 17);
printf("欢迎来到贪吃蛇小游戏!\n");
SetPos(52, 20);
system("pause");
system("cls");
SetPos(39, 16);
printf("用↑↓←→控制蛇的移动,f3加速,f4减速\n");
SetPos(45, 19);
printf("加速可以获得更高的分数\n");
SetPos(49, 26);
system("pause");
system("cls");
}
这里我们继续用到了system函数,使用了两个指令:pause和cls,大家可以记忆一下,这是系统指令,第一个是暂停功能,会让代码停止运行直到你按下任意键,第二个是cls,作用是清除屏幕,避免过去的打印信息影响界面。
我们先把那些还没实现的函数暂时屏蔽一下,运行一下主程序,如果你按照我的代码步骤来,那么应该是会出现以下效果:
我们按下任意键后,会变成:
这样我们的Welcome欢迎函数的实现就完成了,我们把Welcome弹出 顺序表格:
Gamestart的补充:光标隐藏
在我们实现Mapinit之前,我们会发现一个问题,那就是我们在进入Welcome的打印效果界面时,会有黑色的光标在不停跳跃,这就是我们光标位置。但我们是一个键盘游戏,并不需要我们输入什么,所以这个光标的显示就会影响我们的游戏,那我们是不是可以想办法吧这个光标隐藏呢?
查看资料,我们知道Windows API中也有相应函数来实现这个功能:
// 隐藏光标
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(handle, &CursorInfo); // 获取控制台光标信息
CursorInfo.bVisible = false; // 设置可见性为不可见
SetConsoleCursorInfo(handle, &CursorInfo); // 应用更改后的光标信息
一样的,觉得陌生是很正常的,大家可以不用记忆,只需要在以后要用到这些函数的时候查一下文档,问一下ai,知道使用方法就可以了。
我们把上面的代码加入我们的Gamestart函数前面(其实任意地方都行,只要是我们在打印信息之前设置一下就可以了) 注意,我们这里用到了false这个布尔值,在C语言中,想要使用这个需要加入stdbool.h头文件!!!
所以我们的Gamestart代码就变成了这样:
Mapinit的实现
接下来就是来实现Mapinit函数,在此之前,为了方便与代码的简洁,以及后面如果我们想迅速修改这些符号,我们同样可以使用#define 定义宏,来方便的替换我们要打印的地图符号之类的东西。符号是自己定的,在这里可以跟这我选择:
//Snake.h
#define WALL L'□'//墙
#define HEAD L'◎'//蛇头
#define Body L'○'//蛇身
#define Food L'☆'//食物
这里就不得不扩展结束一下,为什么我要使用大写L''的形式:
-
很多特殊符号(如
□
,◎
,☆
)属于 Unicode 字符,无法用单字节char
存储。 -
L'□'
表示 宽字符(wchar_t
类型),可以存储 Unicode 字符(如中文、日文、特殊符号等)。 -
如果不加
L
,某些编译器可能会报错,或者显示乱码。
由于是宽字符,我们不能用printf来打印,这里就要使用wprintf函数来代替,这是用来专门打印unicode宽字符的函数 。
由于各个国家地区的语言符号不同,在此之前,我们可以使用setlocale函数设置本地化,配置程序的本地化规则。
查一下这个函数的使用方法:
我们就可以在程序开始时设置本地化了:
//修改适配本地中文环境
setlocale(LC_ALL, "");//切换到本地环境
把这一行加在main函数的test函数调用前,记得在Snake.h中加入locale.h头文件,这个是调用setlocale(LC_ALL, "")的必要头文件。
具备了以上知识后,我们就知道后面就应该使用wprintf来代替了printf来打印这些符号,而我们的Mapinit函数主要功能是实现游戏地图,也就是我们要打印出一个被墙包围着的地图,所以我们可以调用四个for循环,来打印出游戏的墙壁(记得调用SetPos函数设置打印的初始位置):
void Mapinit()
{
SetPos(0, 0);
for (int i = 0; i <= 68; i += 2)
{
wprintf(L"%c", WALL);
}
SetPos(0, 29);
for (int i = 0; i <= 68; i += 2)
{
wprintf(L"%c", WALL);
}
for (int i = 1; i <= 28; i++)
{
SetPos(0, i);
wprintf(L"%c", WALL);
}
for (int i = 1; i <= 28; i++)
{
SetPos(68, i);
wprintf(L"%lc", WALL);
}
}
有些同学可能还是不知道为什么wprintf要在双引号前面加上L,实际上你只需要把它当做一个规则去记忆使用就行了。
此时,我们可以在Mapinit函数结尾加上system("pause");,来测试一下我们所打印的地图是否成功(这也是一个很重要的思维,我们不能一股脑的把代码写完了才运行测试,而是应该写了一个功能,就测试一个功能。system("pause");只是为了让程序暂停,不结束,后续我们记得把为了测试而加入的system("pause");删除)
地图效果如下:
我们的mapinit函数大概写成这样就差不多了,我们把它弹出顺序表格:
那么接下来我们继续来实现一下snakeinit函数。
Snakeinit的实现
在前面几个初始化的函数中,我们都没有用到Gamestart传入的psnake参数,但是在snakeinit中,我们就需要通过psnake来对我们在test函数中创建的snake变量进行修改。
我们要在snakeinit中实现哪些功能呢?创建蛇的身体,组成链表之后打印蛇身,再加上初始化一下其他参数。
我们可以继续分展开几个小函数,但这里我就把这几个功能合并在一起了。
首先是创建蛇的身体:
我们先声明一个指向蛇节点的指针 cur
,用于在创建和遍历蛇身时使用。随后用for循环五次(由大家自己决定),每一次循环为一个蛇的身躯节点开辟空间,并且给它们赋值(自己的二维坐标)并使得这五个节点连成一个链表。对于蛇的初始坐标,我们可以自己定制,为了方便,想这些数值我们都通过#define来定义一下。
如:
Snake.h:
#define POS_X 2//设置蛇的初始坐标
#define POS_Y 1
Snake.c:
void snakeinit(psnake ps)//蛇的初始化
{
psnakenode cur = NULL;
//初始化蛇身坐标
for (int i = 0; i < 5; i++)
{
cur = (psnakenode)malloc(sizeof(Snakenode));//malloc返回的时空指针类型数据,需要类型强制转化
if (cur == NULL)
{
perror("malloc fail");
exit(1);
}
cur->next = NULL;
cur->x = POS_X + i * 2;
cur->y = POS_Y;
if (ps->psnake == NULL)//psnake是维护贪吃蛇的指针,如果为空,说明贪吃蛇还是第一次创建
{
ps->psnake = cur;
}
else
{
cur->next = ps->psnake;//将后面产生的cur指向上一个cur(psnake)
ps->psnake = cur;//更新蛇头信息
}
//如果是第一个节点,直接赋值给 ps->psnake
//后续节点采用头插法:新节点的next指向当前链表头,然后更新链表头为新节点
//为什么用头插法:这样创建时第一个节点会成为蛇尾,最后一个创建的节点会成为蛇头,符合蛇移动时的逻辑(新头在前,旧尾在后)
}
int head = 1;//判断蛇头
cur = ps->psnake;
//蛇身的打印
while (cur)
{
if (head == 1)
{
SetPos(cur->x, cur->y);
wprintf(L"%c", HEAD);
head = 0;
}
else
{
SetPos(cur->x, cur->y);
wprintf(L"%c", Body);
}
cur = cur->next;
}
//其他参数的初始化
ps->dir = right; //因为我们的贪吃蛇初始蛇头向右,所以设他的方向也为右
ps->pfood = NULL; //我们的食物还没初始化
ps->Score = 0; //此时的得分还为0
ps->speed = 200; //默认的速度,后面我们会将到如何控制所谓的速度
ps->status = OK; //游戏的运行状态为正常
ps->foodweight = 10;//吃一个食物的分值为10分
}
但有一个小问题,在Windows控制台中,每个字符的高度是宽度的两倍,如果X坐标每次只增加1,蛇身会看起来被"压扁"(因为高度方向单位距离是宽度方向的两倍)。如果我们选择水平方向来初始化我们的贪吃蛇(即for循环中用i控制每次节点蛇在x上,就需要在前一个节点上加上i*2,而不是i),并且,我们也要注意一开始的x坐标不应该为奇数,否则就与我们的墙的符号不对齐,出现奇怪的视角问题:
这是当我们把POS_X与POS_Y设置为1的情况,大家可以看到,在水平上有一种不对齐的感觉,并且把墙给挤掉了。
当设置POS_X为2时,又恢复正常:
到这里大家可能觉得我讲的有点快了,跨度过多,实则只要能实现这些功能就行了,大家不必强求细节与我一样,我这篇文章主要给大家提供一种新手写项目的思维。(所以大家不懂的细节,疑惑的地方可以在评论区问我)
那我们就处理好了Snakeinit函数了,将其弹出顺序表格:
foodinit的实现
终于来到我们Gamestart的最后一个函数:foodinit的实现,这个函数也是非常的简单,由于涉及食物节点的创建(之所以要使用节点,是因为蛇吃到食物会长长,刚好适配插入新节点的这个情况),所以我们也需要传入psnake指针:
最主要的是食物的随机出现,为了达成随机这个特性,我们用rand来生成一个随机数,再把它%我们地图的大小,这个生成的随机数就不会超出地图了。
但是我们要小心,食物的x坐标也不应该为奇数,否则就会出现上面的符号不对齐的错误。食物的随机坐标也不应该跟蛇重合:
void foodinit(psnake ps)//食物的初始化
{
int x = 0;
int y = 0;//食物坐标
again:
do
{
x = rand() % 65 + 2;
y = rand() % 28 + 1;
} while (x % 2 != 0);//食物的横坐标不应该为奇数
psnakenode cur = ps->psnake;
while (cur)//食物不能与蛇体重复
{
if (x == cur->x && y == cur->y)//检查是否重复
{
goto again;//倘若重复,使用goto语句回到上面重新生成随机数
}
cur = cur->next;
}
psnakenode food = (psnakenode)malloc(sizeof(Snakenode));//创建食物节点
if (food == NULL)
{
perror("foodcreat malloc fail!");
return;
}
food->x = x;
food->y = y;
food->next = NULL;
ps->pfood = food;//更新全局的pfood维护数据信息
SetPos(food->x, food->y);//打印食物
wprintf(L"%c", Food);
}
但是大家会发现,为什么运行了两次,这个rand生成的随机数出现的顺序是一样的呢?这是因为我们没有设置随机数种子,rand()
函数会产生可预测的伪随机序列。
为了解决这个问题,我们在main开头使用srand来设置随机数,大家把这个代码背住就行(记忆为想使用rand生成真正的随机数,就需要在最开始加一个: srand((unsigned int)time(NULL));)
srand((unsigned int)time(NULL));//把这个代码加在main函数里,设置本地中文环境的前面即可。
time函数返回一个当前的时间戳,时间戳随时在变化,所以我们运行两次代码后不会出现序列相同的随机数。要使用time函数,我们需要先在Snake.h中添加time.h头文件
我们打开Gamestart函数对子函数的屏蔽,运行一下代码:
食物和贪吃蛇的打印就都实现了!
void Gamestart(psnake ps)//完成游戏开始前的初始化
{
system("mode con cols=120 lines=35");
system("title 贪吃蛇");
// 隐藏光标
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(handle, &CursorInfo); // 获取控制台光标信息
CursorInfo.bVisible = false; // 设置可见性为不可见
SetConsoleCursorInfo(handle, &CursorInfo); // 应用更改后的光标信息
//打印欢迎信息
Welcome();
//绘制地图
Mapinit();
//初始化蛇
snakeinit(ps);
//初始化食物
foodinit(ps);
Sleep(20000);
}
以上就是我们对Gamestart的实现,看一下咱们的顺序表格:
四、Gamerun的实现
那么我们接下来的逻辑就是去实现我们的游戏运行代码,包括如何让蛇移动,如何操作蛇的移动,这也是我们运用Windows API的主要战场。
我们游戏地图相对于设定的控制台只占了部分,还有很多空白,这些都是我预留的打印提示信息的位置,比如当前分数,当前食物分数等你想告诉玩家的信息,所以我们可以写一个专门的函数Printhelp来打印变化的数据。
Printhelp的实现
void Printhelp()
{
SetPos(79, 14);
printf("1:不能穿墙,不能碰到自己");
SetPos(79, 16);
printf("2:用↑↓←→来控制蛇的移动");
SetPos(79, 18);
printf("3:F3是加速,F4是减速");
SetPos(79, 20);
printf("按空格space键开始游戏");
}
我们在snake结构体对象中,有着status这个数据,负责咱们的游戏运行情况,当我们的status变化,我们的游戏也会随之进行处理,所以我们可以使用一个do - while循环,while里面一直检测status的情况。
随后就是对do-while循环的处理了。
我们的实时分数这些数据会随着游戏进行而发生一定的改变,所以我们需要在循环里打印,进行更新。同时,我们也应该监测玩家是否按下了一些键盘按键,来对游戏进行不同的处理:
这里对于大家来说是个重灾区,大家可以查一下相关资料GetAsyncKeyState函数:
我们今天会多次用的这个函数,大家使用可以直接跟着我走:
由于这个函数名字又臭又长,函数调用也很麻烦,所以我们可以先定义一个宏:
//定义一个宏检测是否按下相应的虚拟键
#define KEY_PRESS(VK) ((GetAsyncKeyState(VK)&0x1) ? 1 : 0)
这种宏的使用方法大家可能第一次见,就是我们把((GetAsyncKeyState(VK)&0x1) ? 1 : 0)的判断用 KEY_PRESS(VK)来进行一个简洁的替代,其中VK是Windows.h中定义的各种宏参数。
在do-while循环中,我们一直检测按键的按下情况,并进行处理:
void Gamerun(psnake ps)//游戏运行
{
//打印帮助信息
Printhelp();
do
{
//统计当前分数
SetPos(79, 6);
printf("当前游戏分数:%-6d", ps->Score);
SetPos(79, 8);
printf("当前食物分数:%02d", ps->foodweight);
//检测按键
if (KEY_PRESS(VK_UP) && ps->dir != down)//当我们按下上箭头并且蛇的运动状态不朝下时触发
{
ps->dir = up;//将蛇的移动状态改为向上(up是我们一开始定义的一个枚举体参数)
}
else if (KEY_PRESS(VK_DOWN) && ps->dir != up)
{
ps->dir = down;
}
else if (KEY_PRESS(VK_LEFT) && ps->dir != right)
{
ps->dir = left;
}
else if (KEY_PRESS(VK_RIGHT) && ps->dir != left)
{
ps->dir = right;
}
else if (KEY_PRESS(VK_ESCAPE))
{
ps->status = ESC;
}
else if (KEY_PRESS(VK_SPACE))
{
//暂停游戏
pause();
}
else if (KEY_PRESS(VK_F3))//按下F3/F4键后,进行速度参数的控制
{
if (ps->speed >= 80)
{
ps->speed -= 30;
ps->foodweight += 2;
}
}
else if (KEY_PRESS(VK_F4))
{
if (ps->foodweight > 2)
{
ps->speed += 30;
ps->foodweight -= 2;
}
}
//VK_SPACE VK_F4 VK_F3都是Windows.h自带的对应键盘按下的宏定义,大家可以查一下表格
//由于循环在不停的进行,所以大家不用担心是否会出现延迟的问题。
//贪吃蛇的移动
snakemove(ps);//用来实现移动的函数
Sleep(ps->speed);//Sleep是一个库函数,Sleep(1000)就是暂停一秒,咱们通过Sleep来实现那种一帧一帧的效果
} while (ps->status == OK);
}
//VK_SPACE VK_F4 VK_F3都是Windows.h自带的对应键盘按下的宏定义,大家可以查一下表格
整体的代码实现如上,有问题可以问我。
在这个过程中,出现了两个新函数,负责控制蛇的移动,以及暂停游戏的功能:
snakemove的实现
snakemove
是贪吃蛇游戏中最核心的函数之一,负责处理蛇的移动逻辑。我们应该有五个主要逻辑:
-
创建新节点作为移动目标
-
计算移动后的新位置
-
判断新位置类型(食物/空格/障碍)
-
处理食物或正常移动
-
碰撞检测
为什么要创建一个新节点呢,大家可以想一下:如果直接修改蛇头节点的坐标,会导致链表状态在移动过程中暂时不一致(例如:先改蛇头坐标,但还未处理蛇身移动时)。所以我们通过创建临时节点 move
,先计算目标位置,确认安全后再实际插入链表,保证操作原子性。
随后就是检查当前贪吃蛇的移动方向,根据方向计算出头节点即将到达的地方(这里可以用switch条件判断)。随后继续判断是否会在移动后吃到食物,检查是否撞击墙壁或者自己导致游戏结束。
void snakemove(psnake ps)//贪吃蛇的移动
{
//先创建一个结点,作为蛇接下来即将移动的格子
psnakenode move = (psnakenode)malloc(sizeof(Snakenode));
if (move == NULL)
{
perror("snakemove malloc fail!");
return;
}
move->next = NULL;
//算出移动的坐标
switch (ps->dir)
{
case up://根据速度方向来给move节点赋值
move->x = ps->psnake->x;
move->y = ps->psnake->y - 1;
break;
case down:
move->x = ps->psnake->x;
move->y = ps->psnake->y + 1;
break;
case left:
move->x = ps->psnake->x - 2;
move->y = ps->psnake->y;
break;
case right:
move->x = ps->psnake->x + 2;
move->y = ps->psnake->y;
break;
}
//分析蛇接下来将会移动到哪里:墙,食物,空格
//如果是食物就吃掉
if (ps->pfood->x == move->x && ps->pfood->y == move->y)
{
eatfood(ps, move);
}
else//不是就正常行走
{
noteatfood(ps, move);
}
//检测是否撞墙
ifhitwall(ps);
//检测是否撞到自己
ifhitself(ps);
}
为了实现这些功能,我们又在snakemove
函数中封装几个新函数:eatfood,noteatfood,ifhitwall ,ifhitself
eatfood与noteatfood
那么我们的eatfood与noteatfood这两个函数又应该有着什么功能呢?我们该如何实现。
由于一开始设定蛇吃到食物会变长,所以我们用节点来充当食物,所以每一次的吃食物,都是把食物节点插入贪吃蛇链表中。当我们没有吃到食物时,在snakemove中一开始定义的move节点也会被插入链表,但是作为新的头节点,他的尾结点会被删除,同时,我们也应该把之前打印的尾巴用空白所代替。否则之前的打印效果依然会存在在地图上面。
void eatfood(psnake ps, psnakenode move)
{
move->next = ps->psnake;
ps->psnake = move;//把维护贪吃蛇的指针指向move,move的next指向之前的头,这样move就成为了新的头节点
//打印蛇
psnakenode cur = ps->psnake;
int head = 1;
while (cur)
{
if (head == 1)
{
SetPos(cur->x, cur->y);
wprintf(L"%c", HEAD);
head = 0;
}
else
{
SetPos(cur->x, cur->y);
wprintf(L"%c", Body);
}
cur = cur->next;
}
//更新数据,吃掉食物后我们的得分会增加
ps->Score += ps->foodweight;
//释放旧食物
free(ps->pfood);
//创建新食物
foodinit(ps);
}
在这个eatfood的代码中,我们最后free掉了ps所指向的食物指针,这是因为食物指针所指向的节点是我们在foodinit中创建的,但是我们插入链表的是snakemove中新创建的节点,我们比较是否下一步移动到食物节点,是通过两个的x与y判断的,并不是真正的判断二者指针是否指向同一位置。所以我们需要把ps->food释放掉,随后在复用foodinit函数,重新随机生成我们的食物。
同理我们也可以写出noteatfood函数的处理:
void noteatfood(psnake ps, psnakenode move)
{
move->next = ps->psnake;
ps->psnake = move;
//释放尾节点
psnakenode cur = ps->psnake;
int head = 1;
while (cur->next->next)
{
if (head == 1)
{
SetPos(cur->x, cur->y);
wprintf(L"%c", HEAD);
head = 0;
}
else
{
SetPos(cur->x, cur->y);
wprintf(L"%c", Body);
}
cur = cur->next;
}
//将尾结点打印为空
SetPos(cur->next->x, cur->next->y);
printf(" ");
free(cur->next);//释放尾结点
cur->next = NULL;//置为空防止出现野指针
}
ifhitwall与ifhitself
之后就是我们对是否碰墙,是否碰撞到自己的检测处理。由于我们游戏运行的逻辑是不断的执行do-while循环,while中是我们游戏运行的标准:即ps所指向的status(状态)是否为OK。如果我们在游戏过程中撞到了墙,或者咬到了自己,就把status状态改变,随后就自动的结束了我们的do-while循环。
代码比较简单:
void ifhitwall(psnake ps)//是否撞到墙
{
if (ps->psnake->x == 0 ||
ps->psnake->x == 68 ||
ps->psnake->y == 0 ||
ps->psnake->y == 29)
{
ps->status = KILL_BY_WALL;
}
}
void ifhitself(psnake ps)
{
psnakenode cur = ps->psnake->next;//从第二个节点开始检查是否撞到自己
while (cur)
{
if (cur->x == ps->psnake->x && cur->y == ps->psnake->y)
{
ps->status = KILL_BY_SELF;//改变状态
return;
}
cur = cur->next;
}
}
那么就差不多把snakemove函数写完了。
pause的实现
接下来,我们该如何实现pause函数呢?
暂停功能,就是让其他函数,代码都不被调用,我们不主动恢复,就一直暂停。
这种特性是不是与我们的死循环一样啊?
那么就可以用一个while(true)循环,一调用pause,就会置入这个死循环中,然后我们在这个循环中结合Windows API,随时检索SPACE的再度按下,就是解除暂停,随后我们就可以主动break跳出循环,继续运行其它代码了。
void pause()
{
while (true)
{
if (KEY_PRESS(VK_SPACE))
{
break;//跳出循环
}
}
}
当暂停功能完成后,我们的Gamerun也就完成的差不多了,这个时候我们的贪吃蛇游戏已经可以初步运行了:
五、Gameend的实现
我们可以通过上下左右键操控我们的贪吃蛇,蛇死亡后,游戏就会结束。但是我们最后还有很多数据没释放,并且,如果用户想再来一次,还需要重新运行程序,所以我们也可以添加一个再来一次的功能,这些都要靠我们的Gameend来实现:
大家再加把油,我们马上就把我们的小游戏完成了!!
对于这个函数,我们主要就是打印我们的结束信息,告诉玩家分数,以及询问玩家是否再来一次贪吃蛇游戏:
void Gameend(psnake ps)
{
int flag = 1;//最后根据flag判断用户是否想再玩一次游戏
SetPos(30, 14);
switch (ps->status)
{
case KILL_BY_WALL:
printf("撞到了墙,游戏结束!");
SetPos(31, 15);
printf("最后得分:%d", ps->Score);
break;
case ESC:
printf("退出游戏成功!");
flag = 0;
break;
case KILL_BY_SELF:
printf("咬到自己了,游戏失败!");
SetPos(31, 15);
printf("最后得分:%d", ps->Score);
break;
}
//释放链表内存空间
while (ps->psnake)
{
psnakenode cur = ps->psnake;
ps->psnake = ps->psnake->next;
free(cur);
}
ps->psnake = NULL;
free(ps->pfood);
ps->pfood = NULL;
if (flag)
{
countdown();//打印倒计时
}
}
我们在do-while循环中一直在检测ESC的按下信息,如果被按下了,ps->status就会改变为ESC,意思为退出游戏!
countdown与oncegain
如果没有按下,在我们释放了链表的内存后,就执行countdown()来打印倒计时。我们可以在countdown中倒计时任意秒,其中不断接受用户按键信息,从而判断是否再来一次,如果是,就可以执行再来一次的操作,我们可以封装一个onceagain函数来专门执行再来一次操作!
void countdown()
{
SetPos(22, 17);
printf("还要再来一次吗?按(space)再来一次");
for (int i = 5; i >= 0; i--)//我这里设置的是五秒钟
{
Sleep(1000);
SetPos(50, 19);
printf("%d", i);
if (KEY_PRESS(VK_SPACE))
{
onceagain();
}
else if (KEY_PRESS(VK_ESCAPE))
{
break;
}
}
system("cls");
}
void onceagain()//执行再来一次操作,重新运行Gamestart,run,end三个函数
{
Snake snake = { 0 };
Gamestart(&snake);//完成游戏开始前的初始化
Gamerun(&snake);//游戏的运行
Gameend(&snake);
}
这样,我们最基本的贪吃蛇框架就完成了!快发给你的好朋友一起游玩吧!!
附上全部代码:
Snake.h:
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<Windows.h>
#include<stdbool.h>
#include<locale.h>
#include<time.h>
//定义一个宏检测是否按下相应的虚拟键
#define KEY_PRESS(VK) ((GetAsyncKeyState(VK)&0x1) ? 1 : 0)
#define WALL L'□'
#define HEAD L'◎'
#define Body L'○'
#define Food L'☆'
#define POS_X 34
#define POS_Y 14
enum game_status //游戏运行状态
{
OK = 1,
ESC,//退出
KILL_BY_WALL,//被墙杀死
KILL_BY_SELF//被自己杀死
};
enum Dir_move//蛇的移动方向
{
right,
left,
up,
down
};
typedef struct Snakenode//对贪吃蛇躯体的维护
{
int x;
int y;
struct Snakenode* next;//指向下个节点躯体
}Snakenode, * psnakenode;
typedef struct Snake//对贪吃蛇游戏的维护
{
psnakenode psnake;//维护蛇的指针
psnakenode pfood;//蛇的食物也是蛇身体的节点,用一个指针找到这个食物
int foodweight;//此时食物的分值
int Score;//分数
int speed;//蛇的速度,也就是休眠的时间,时间越短,速度越快
enum game_status status;//游戏运行的状态
enum Dir_move dir;//蛇移动的方向
}Snake, * psnake;
void Gamestart(psnake ps);//完成游戏开始前的初始化
void Gamerun(psnake ps);//游戏的运行
void Gameend(psnake ps);//游戏结束后的善后工作
void SetPos(int, int);//设置光标位置
void Welcome();//游戏开始时的初始界面打印
void Mapinit();//打印地图
void snakeinit(psnake ps);//蛇的初始化
void foodinit(psnake ps);//食物的初始化
void Printhelp();//打印帮助信息
void snakemove(psnake ps);//贪吃蛇的移动
void eatfood(psnake ps, psnakenode move);//贪吃蛇的吃食物过程
void noteatfood(psnake ps, psnakenode move);//没有吃到食物的处理
void ifhitwall(psnake ps);//是否撞到墙
void ifhitself(psnake ps);//是否咬到自己
void pause();//暂停功能
Snake.c:
#include"Snake.h"
void SetPos(int x, int y)
{
//获得设备句柄
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
//根据获得的句柄来设置信息
COORD pos = { x,y };//光标将要到达的坐标
SetConsoleCursorPosition(handle, pos);//调用函数,这些陌生函数记住作用,死记硬背即可
}
void Welcome()
{
SetPos(50, 17);
printf("欢迎来到贪吃蛇小游戏!\n");
SetPos(52, 20);
system("pause");
system("cls");
SetPos(39, 16);
printf("用↑↓←→控制蛇的移动,f3加速,f4减速\n");
SetPos(45, 19);
printf("加速可以获得更高的分数\n");
SetPos(49, 26);
system("pause");
system("cls");
}
void Mapinit()
{
SetPos(0, 0);
for (int i = 0; i <= 68; i += 2)
{
wprintf(L"%c", WALL);
}
SetPos(0, 29);
for (int i = 0; i <= 68; i += 2)
{
wprintf(L"%c", WALL);
}
for (int i = 1; i <= 28; i++)
{
SetPos(0, i);
wprintf(L"%c", WALL);
}
for (int i = 1; i <= 28; i++)
{
SetPos(68, i);
wprintf(L"%lc", WALL);
}
}
void snakeinit(psnake ps)//蛇的初始化
{
psnakenode cur = NULL;
//初始化蛇身坐标
for (int i = 0; i < 5; i++)
{
cur = (psnakenode)malloc(sizeof(Snakenode));//malloc返回的时空指针类型数据,需要类型强制转化
if (cur == NULL)
{
perror("malloc fail");
exit(1);
}
cur->next = NULL;
cur->x = POS_X + i * 2;
cur->y = POS_Y;
if (ps->psnake == NULL)//psnake是维护贪吃蛇的指针,如果为空,说明贪吃蛇还是第一次创建
{
ps->psnake = cur;
}
else
{
cur->next = ps->psnake;//将后面产生的cur指向上一个cur(psnake)
ps->psnake = cur;//更新蛇头信息
}
//如果是第一个节点,直接赋值给 ps->psnake
//后续节点采用头插法:新节点的next指向当前链表头,然后更新链表头为新节点
//为什么用头插法:这样创建时第一个节点会成为蛇尾,最后一个创建的节点会成为蛇头,符合蛇移动时的逻辑(新头在前,旧尾在后)
}
int head = 1;//判断蛇头
cur = ps->psnake;
//蛇身的打印
while (cur)
{
if (head == 1)
{
SetPos(cur->x, cur->y);
wprintf(L"%c", HEAD);
head = 0;
}
else
{
SetPos(cur->x, cur->y);
wprintf(L"%c", Body);
}
cur = cur->next;
}
//其他参数的初始化
ps->dir = right; //因为我们的贪吃蛇初始蛇头向右,所以设他的方向也为右
ps->pfood = NULL; //我们的食物还没初始化
ps->Score = 0; //此时的得分还为0
ps->speed = 200; //默认的速度,后面我们会将到如何控制所谓的速度
ps->status = OK; //游戏的运行状态为正常
ps->foodweight = 10;//吃一个食物的分值为10分
}
void Printhelp()//打印帮助信息
{
SetPos(79, 14);
printf("1:不能穿墙,不能碰到自己");
SetPos(79, 16);
printf("2:用↑↓←→来控制蛇的移动");
SetPos(79, 18);
printf("3:F3是加速,F4是减速");
SetPos(79, 20);
printf("按空格space键开始游戏");
}
void foodinit(psnake ps)//食物的初始化
{
int x = 0;
int y = 0;
again:
do
{
x = rand() % 65 + 2;
y = rand() % 28 + 1;
} while (x % 2 != 0);//食物的横坐标不应该为奇数
psnakenode cur = ps->psnake;
while (cur)//食物不能与蛇体重复
{
if (x == cur->x && y == cur->y)
{
goto again;
}
cur = cur->next;
}
psnakenode food = (psnakenode)malloc(sizeof(Snakenode));
if (food == NULL)
{
perror("foodcreat malloc fail!");
return;
}
food->x = x;
food->y = y;
food->next = NULL;
ps->pfood = food;
SetPos(food->x, food->y);//打印食物
wprintf(L"%c", Food);
}
void Gamestart(psnake ps)//完成游戏开始前的初始化
{
system("mode con cols=120 lines=35");
system("title 贪吃蛇");
// 隐藏光标
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(handle, &CursorInfo); // 获取控制台光标信息
CursorInfo.bVisible = false; // 设置可见性为不可见
SetConsoleCursorInfo(handle, &CursorInfo); // 应用更改后的光标信息
//打印欢迎信息
Welcome();
//绘制地图
Mapinit();
//初始化蛇
snakeinit(ps);
//初始化食物
foodinit(ps);
}
void eatfood(psnake ps, psnakenode move)
{
move->next = ps->psnake;
ps->psnake = move;//把维护贪吃蛇的指针指向move,move的next指向之前的头,这样move就成为了新的头节点
//打印蛇
psnakenode cur = ps->psnake;
int head = 1;
while (cur)
{
if (head == 1)
{
SetPos(cur->x, cur->y);
wprintf(L"%c", HEAD);
head = 0;
}
else
{
SetPos(cur->x, cur->y);
wprintf(L"%c", Body);
}
cur = cur->next;
}
//更新数据,吃掉食物后我们的得分会增加
ps->Score += ps->foodweight;
//释放旧食物
free(ps->pfood);
//创建新食物
foodinit(ps);
}
void snakemove(psnake ps)//贪吃蛇的移动
{
//先创建一个结点,作为蛇接下来即将移动的格子
psnakenode move = (psnakenode)malloc(sizeof(Snakenode));
if (move == NULL)
{
perror("snakemove malloc fail!");
return;
}
move->next = NULL;
//算出移动的坐标
switch (ps->dir)
{
case up://根据速度方向来给move节点赋值
move->x = ps->psnake->x;
move->y = ps->psnake->y - 1;
break;
case down:
move->x = ps->psnake->x;
move->y = ps->psnake->y + 1;
break;
case left:
move->x = ps->psnake->x - 2;
move->y = ps->psnake->y;
break;
case right:
move->x = ps->psnake->x + 2;
move->y = ps->psnake->y;
break;
}
//分析蛇接下来将会移动到哪里:墙,食物,空格
//如果是食物就吃掉
if (ps->pfood->x == move->x && ps->pfood->y == move->y)
{
eatfood(ps, move);
}
else//不是就正常行走
{
noteatfood(ps, move);
}
//检测是否撞墙
ifhitwall(ps);
//检测是否撞到自己
ifhitself(ps);
}
void noteatfood(psnake ps, psnakenode move)
{
move->next = ps->psnake;
ps->psnake = move;
//释放尾节点
psnakenode cur = ps->psnake;
int head = 1;
while (cur->next->next)//打印蛇身
{
if (head == 1)
{
SetPos(cur->x, cur->y);
wprintf(L"%c", HEAD);
head = 0;
}
else
{
SetPos(cur->x, cur->y);
wprintf(L"%c", Body);
}
cur = cur->next;
}
//将尾结点打印为空
SetPos(cur->next->x, cur->next->y);
printf(" ");
free(cur->next);//释放尾结点
cur->next = NULL;//置为空防止出现野指针
}
void pause()
{
while (true)
{
if (KEY_PRESS(VK_SPACE))
{
break;//跳出循环
}
}
}
void ifhitwall(psnake ps)//是否撞到墙
{
if (ps->psnake->x == 0 ||
ps->psnake->x == 68 ||
ps->psnake->y == 0 ||
ps->psnake->y == 29)
{
ps->status = KILL_BY_WALL;
}
}
void ifhitself(psnake ps)
{
psnakenode cur = ps->psnake->next;//从第二个节点开始检查是否撞到自己
while (cur)
{
if (cur->x == ps->psnake->x && cur->y == ps->psnake->y)
{
ps->status = KILL_BY_SELF;//改变状态
return;
}
cur = cur->next;
}
}
void Gamerun(psnake ps)//游戏运行
{
//打印帮助信息
Printhelp();
do
{
//统计当前分数
SetPos(79, 6);
printf("当前游戏分数:%-6d", ps->Score);
SetPos(79, 8);
printf("当前食物分数:%02d", ps->foodweight);
/*SetPos(79, 10);
printf("历史最高分数:%d", maxscore);*/
//检测按键
if (KEY_PRESS(VK_UP) && ps->dir != down)//当我们按下上箭头并且蛇的运动状态不朝下时触发
{
ps->dir = up;//将蛇的移动状态改为向上(up是我们一开始定义的一个枚举体参数)
}
else if (KEY_PRESS(VK_DOWN) && ps->dir != up)
{
ps->dir = down;
}
else if (KEY_PRESS(VK_LEFT) && ps->dir != right)
{
ps->dir = left;
}
else if (KEY_PRESS(VK_RIGHT) && ps->dir != left)
{
ps->dir = right;
}
else if (KEY_PRESS(VK_ESCAPE))
{
ps->status = ESC;
}
else if (KEY_PRESS(VK_SPACE))
{
//暂停游戏
pause();
}
else if (KEY_PRESS(VK_F3))//按下F3/F4键后,进行速度参数的控制
{
if (ps->speed >= 80)
{
ps->speed -= 30;
ps->foodweight += 2;
}
}
else if (KEY_PRESS(VK_F4))
{
if (ps->foodweight > 2)
{
ps->speed += 30;
ps->foodweight -= 2;
}
}
//VK_SPACE VK_F4 VK_F3都是Windows.h自带的对应键盘按下的宏定义,大家可以查一下表格
//由于循环在不停的进行,所以大家不用担心是否会出现延迟的问题。
//贪吃蛇的移动
snakemove(ps);//用来实现移动的函数
Sleep(ps->speed);//Sleep是一个库函数,Sleep(1000)就是暂停一秒,咱们通过Sleep来实现那种一帧一帧的效果
} while (ps->status == OK);
}
void countdown()
{
SetPos(22, 17);
printf("还要再来一次吗?按(space)再来一次");
for (int i = 5; i >= 0; i--)//我这里设置的是五秒钟
{
Sleep(1000);
SetPos(50, 19);
printf("%d", i);
if (KEY_PRESS(VK_SPACE))
{
onceagain();
}
else if (KEY_PRESS(VK_ESCAPE))
{
break;
}
}
system("cls");
}
void onceagain()//执行再来一次操作,重新运行Gamestart,run,end三个函数
{
Snake snake = { 0 };
Gamestart(&snake);//完成游戏开始前的初始化
Gamerun(&snake);//游戏的运行
Gameend(&snake);
}
void Gameend(psnake ps)
{
int flag = 1;//最后根据flag判断用户是否想再玩一次游戏
SetPos(30, 14);
switch (ps->status)
{
case KILL_BY_WALL:
printf("撞到了墙,游戏结束!");
SetPos(31, 15);
printf("最后得分:%d", ps->Score);
break;
case ESC:
printf("退出游戏成功!");
flag = 0;
break;
case KILL_BY_SELF:
printf("咬到自己了,游戏失败!");
SetPos(31, 15);
printf("最后得分:%d", ps->Score);
break;
}
//释放链表内存空间
while (ps->psnake)
{
psnakenode cur = ps->psnake;
ps->psnake = ps->psnake->next;
free(cur);
}
ps->psnake = NULL;
free(ps->pfood);
ps->pfood = NULL;
if (flag)
{
countdown();//打印倒计时
}
}
main.c:
#include"Snake.h"
void test()
{
//创建贪吃蛇
Snake snake = { 0 };
Gamestart(&snake);//完成游戏开始前的初始化
Gamerun(&snake);//游戏的运行
Gameend(&snake);//游戏结束后的善后工作
}
int main()
{
srand((unsigned int)time(NULL));
//修改适配本地中文环境
setlocale(LC_ALL, "");//切换到本地环境
test();
return 0;
}
大家可以参考一下。
拓展与修改:BGM与历史记录
后面会是我们的选学内容,是我后面对其基础功能的一个拓展,比如用文件管理记录历史数据,调用 Windows 多媒体 API来进行一个音乐播放的功能。
想要播放一个音乐,我们有着许多方式,今天就介绍一种使用Windows 多媒体 API播放音乐功能的代码。想要使用这个,就需要在Snake.h中加入
#include <mmsystem.h>
#pragma comment(lib, "winmm.lib")//链接winmm.lib库
这个函数的使用具有一定操作难度,还是那句话,大家可以先学着接受,学着使用,最后学会理解。
我们在test.c中添加以下代码:
void musicplay()
{
MCIERROR ret = mciSendString("open \".\\music\\king.mp3\" alias bgm", NULL, 0, NULL);//使用的相对路径,寻找对应文件
if (ret != 0)
{
char err[100] = { 0 };
mciGetErrorString(ret, err, sizeof(err));
//puts(err);
printf("Error: %s\n", err);
}
mciSendString("play bgm repeat", NULL, 0, NULL);//播放音乐
}
通过对 mciSendString的调用,我们可以打开处于这个路径下的音乐文件。注意,这个路径必须真实有效,否则将无法播放。除了相对路径以外,也支持绝对路径的记载,但是这样的可移植性就十分低下,在别人的电脑上就可能有exe文件无法正常运行的问题。
我们用musicplay函数打开了音乐,在结束时也需要关闭,所以我们需要在test函数的结束前写入下面代码关闭音乐:
mciSendString("close bgm", NULL, 0, NULL);
通过这个简单的函数我们就能实现音乐的播放功能,这个函数还有很多功能,大家可以查看文档自行学习一下。
下面我们可以用C语言所学到的文件管理操作来实现一个历史记录的实现,在这里我就只实现一下历史最高分数的实现,其他历史记录其实都是同一个道理。
历史分数就是一个整数类型数据,我们可以通过打开文件操作,读取文件中所存储的这个数。在我们代码中,可以在test.c中定义一个全局变量
int maxscore = 0;//全局变量表示最高分数
随后在游戏开始前调用函数来读取操作,给全局变量赋值,如果当每一把游戏结束时判断,当前游戏分数是否高于maxscore,高于就更新,最后在程序结束时将maxscore再存入文件中就行了。
代码实现如下:
void loadHighestScore()
{
// 尝试以只读模式打开最高分记录文件
FILE* pf = fopen("max_score.txt", "r");
// 如果文件打开失败(文件不存在)
if (pf == NULL)
{
// 以写入模式创建新文件
pf = fopen("max_score.txt", "w");
// 将当前最高分(maxscore)写入文件
fprintf(pf, "%d", maxscore);
// 关闭文件
fclose(pf);
pf = NULL; // 将指针置NULL是良好的编程习惯
}
else // 文件存在的情况
{
// 从文件中读取历史最高分到maxscore变量
fscanf(pf, "%d", &maxscore);
// 关闭文件
fclose(pf);
}
// 注意:函数结束后,maxscore变量将包含:
// 1. 文件存在时 -> 文件中的历史最高分
// 2. 文件不存在时 -> 保持原来的maxscore值(因为刚被写入文件)
}
void saveHighestScore()
{
// 以写入模式("w")打开文件,如果文件不存在则创建,存在则清空
FILE* pf = fopen("max_score.txt", "w");
// 检查文件是否成功打开
if (pf == NULL)
{
// 使用perror输出详细的错误信息(包含错误原因)
perror("fopen fail");
return; // 提前返回,避免后续操作
}
// 将当前最高分以十进制格式写入文件
fprintf(pf, "%d", maxscore);
// 确保数据完全写入磁盘(刷新缓冲区)
fflush(pf);
// 关闭文件句柄,释放系统资源
fclose(pf);
// 注意:在写入模式下:
// 1. 文件不存在时会自动创建
// 2. 文件存在时会清空原有内容
}
大家最好养成一个好习惯,把这写函数都写在Snake的对应.c与.h文件中。有些同学可能会细心发现,我在main.c定义的全局变量,你让我把函数写在Snake.c中,那么我们如何使用到这个变量呢?
对于同一个项目下的.c文件,我们可以通过在Snake.c中使用extern关键字来使用其他.c文件定义的全局变量。
extern int maxscore;
最后我们将这些新增的功能函数加入我们的test函数内:
void test()
{
musicplay();
//创建贪吃蛇
Snake snake = { 0 };
//读取历史最高分数
loadHighestScore();
Gamestart(&snake);//完成游戏开始前的初始化
Gamerun(&snake);//游戏的运行
Gameend(&snake);//游戏结束后的善后工作
//关闭多媒体设备
mciSendString("close bgm", NULL, 0, NULL);
saveHighestScore();
}
注意,BGM的播放,与路径紧紧相关,倘若相对路径找不到,大家可以尝试使用绝对路径来代替相对路径!
结语
那么,这样一个新增了BGM功能的贪吃蛇小游戏就完成啦!欢迎大家勇于尝试,从小项目练起,不断锻炼自己的写项目的能力,希望本文对你有所帮助!!有任何疑问都可以在评论区提出!