目录
贪吃蛇,一个相当经典的小游戏,相信各位或多或少都玩过或是听过。而如果要实现这个小游戏的话,我们就需要熟悉 结构体、指针 以及 链表 的相关知识
功能介绍
- 背景音乐播放
- 贪吃蛇地图的打印
- 吃食物边长
- 贪吃蛇的移动
- 计算得分
- 撞墙与撞自身结束游戏并打开原神相关网站
- 贪吃蛇的加速减速
- 暂停游戏
作者写的游戏提取
https://pan.baidu.com/s/1br9QsxLJpWF8Rgq-xE2JRg?pwd=1111
提取码: 1111
游戏已经放在上面了,各位可以在电脑上玩玩看
Win32 API 相关知识介绍
Win32 API 中有许多函数,我们今天将会学习里面的几种函数以帮助我们实现贪吃蛇小游戏
控制台程序
首先,我们需要设置控制台的大小与名字,我们平常运行程序时出现的框框就是控制台,如下
这里我们需要用到 system 函数,引头文件 # include <stdlib.h>
system("mode con cols=100 lines=30");
通过如上代码,我们就可以将控制台设置成一个 100 * 30 的矩形
同时我们还可以将控制台的名字改成游戏的名字
system("title 贪吃蛇");
单独使用如上代码时,我们会发现控制台并没有依照我们的预期将名字改成贪吃蛇
这是因为程序运行得太快了,当程序结束的时候,名字也就自动恢复了
对此,我们可以使用 system("pause"); 将程序暂停一下以观察效果,如下
COORD
COORD是Win32 API中定义的一个结构体,使用时需要引头文件 #include <windows.h>,如下
typedef struct _COORD {
SHORT X;
SHORT Y;
} COORD, * PCOORD;
当我们需要在我们想要的位置输入内容时,比如想在控制台中间输入“ 欢迎加入贪吃蛇 ”,我们就需要用到COORD(因为控制台总是在最左上角的位置开始打印)
COORD pos = { 1, 1 };
通过如上代码,我们就可以对坐标进行赋值
GetStdHandle
这是一个非常重要的函数,其作用是获得特定设备的句柄(标识不同设备的数值)
看到这里可能会有人不知道 句柄 是什么,那我就简单解释一下(以下内容单纯是为了理解):
我们提桶时需要一个把手,这样才能更好地将这个桶提起来,而这个把手,就是桶的句柄; 炒菜时用锅,锅的把手就是锅的句柄
而我们程序运行时,有了程序的句柄,我们才能更好地进行各种操作,而程序的句柄就是一个数值,每个数值都代表一个特定的设备,GetStdHandle 函数的结构如下
HANDLE GetStdHandle(DWORD nStdHandle);
可以看到,该函数会返回一个 HANDLE 类型的数据,我们在使用时就只需要创建一个 HANDLE 类型的数据,并且使用该数据接收 GetStdHandle 函数的返回值就可以了
而该函数所需的参数是什么?如下:
该函数需要的参数就上面三种,我们实现贪吃蛇游戏所需的就是上面的STD_OUTPUT_HANDLE
使用实例如下:
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
依照如上代码,我们就能获得设备的句柄
GetConsoleCursorInfo
为了隐藏光标,所以我们学习这个函数,因为游戏运行时总有光标在闪就不大美观
看该函数的名称就知道,这个函数是获取控制台光标信息的,而其语法结构如下:
BOOL WINAPI GetConsoleCursorInfo(
_In_ HANDLE hConsoleOutput,
_Out_ PCONSOLE_CURSOR_INFO lpConsoleCursorInfo
);
如上,该函数的第一个参数是设备的句柄,我们通过 GetStdHandle 函数可以获取
第二个参数是一个指针,一个指向 CONSOLE_CURSOR_INFO 结构的指针,该结构体语法结构如下:
typedef struct _CONSOLE_CURSOR_INFO {
DWORD dwSize;
BOOL bVisible;
} CONSOLE_CURSOR_INFO, *PCONSOLE_CURSOR_INFO;
第一个成员是表示光标占比的,1% ~ 100%。我们看到的大多数光标的占比是25%,而如果我们将其设置为100%,效果将会是这样的(此处会预先使用到下面会讲的 SetConsoleCursorInfo):
第二个成员是表示光标可见性的,也就是光标能否被看见就由第二个成员决定,如果我们不想让光标显示出来的话,我们只需要将结构体成员 bVisible 置为 false 就可以了,但由于 false 编辑器不认识,所以我们需要引头文件 #include <stdbool.h>,如下:
#include <stdbool.h>
cursorinfo.bVisible = false;
SetConsoleCursorInfo
这个函数就好比,有个人请求你帮他修理一个东西,你拿到了这个东西,修理完之后,你得还给人家让人家检查检查是不是真的修好了
而我们前面的知识都是在讲怎么拿到东西以及怎么修理这个东西的,现在要讲的就是怎么将这个东西还回去并拿到相应的报酬
SetConsoleCursorInfo 函数的语法和 GetConsoleCursorInfo 函数是差不多的,如下:
BOOL WINAPI SetConsoleCursorInfo(
_In_ HANDLE hConsoleOutput,
_In_ const CONSOLE_CURSOR_INFO *lpConsoleCursorInfo
);
我们可以看到,这里面的两个参数分别是句柄和一个指针(结构体),和 GetConsoleCursorInfo 函数是一样的
综上,隐藏光标的代码如下:
CONSOLE_CURSOR_INFO cursorinfo;
//定义出CONSOLE_CURSOR_INFO类型的结构体,名字为cursorinfo
GetConsoleCursorInfo(handle, &cursorinfo);
//获取光标信息
cursorinfo.bVisible = false;
//将结构体内的光标信息更改为为不可见
SetConsoleCursorInfo(handle, &cursorinfo);
//设置指定设备光标的可见性
SetConsoleCursorPosition
这个函数跟我们上面看到的 SetConsoleCursorInfo 函数是非常相似的
我们先来看一看该函数的语法:
BOOL WINAPI SetConsoleCursorPosition(
HANDLE hConsoleOutput,
COORD pos
);
我们可以看到,这个函数的的第一个参数是句柄
第二个参数是坐标信息,也就是 COORD 类型结构体内的成员
所以设置光标位置的代码如下:
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
//获得句柄
COORD pos = { 46, 15 };
//定义坐标信息
SetConsoleCursorPosition(handle, pos);
//设置光标位置
我们可以加一个 scanf 让程序停下来看看效果:
GetAsyncKeyState
如果我们要让蛇 上下左右 移动的话,那么就必须要使用到我们的键盘,但是我们编写程序又该如何知道哪个按键是否被按过呢?
这时我们就可以使用 GetAsyncKeyState 函数,其语法结构如下:
SHORT GetAsyncKeyState(
int vKey
);
这个函数需要你传一个虚拟键值进去,然后该函数会检测,传进去的虚拟键值所代表的按键是否被按过
返回一个 short 类型的数据
如果返回的这个数据的二进制位的最高位为1,则代表该键正在被按着
如果返回的这个数据的二进制位的最高位为0,则代表该键现在没有被按着
如果返回的这个数据的二进制位的最低位为1,则代表该键被按过
如果返回的这个数据的二进制位的最低位为0,则代表该键没被按过
我们今天就实现一个简单点的,只要按键被按过,我们就加速/减速
而要判断的话,我们可以通过按位与0X1来判断,如下是按位与的知识点
5 & 3
5 1 0 1
3 0 1 1
结果 0 0 1
所以 5 & 3 的结果为1
如果返回值被 按位与(&)一个0X1的话,那么如果该按键被按过,那么返回值的最低位就一定为1,又因为按位与(&)是两个都为 1 结果才为 1
所以当我们将返回值按位与 1,那么结果如果为1,就代表这个键被按过;如果结果为0,则代表该键没有被按过
但是如果我们每次都要将其结果 &1 的话,那么就会显得代码很冗杂,我们可以用 #define 定义一个宏,而结果的话我们可以用三目操作符,这样我们就可以返回int型的1与0,如下:
#define KEY_PRESS(VK) ( GetAsyncKeyState(VK) & 0x1 ? 1 : 0 )
如上,我们仅需要输入一个 KEY_PRESS(虚拟键值)就可以获取当前按键的状态了
虚拟键值表如下:
https://learn.microsoft.com/zh-cn/windows/win32/inputdev/virtual-key-codes
贪吃蛇准备阶段(GameStart)
定位函数包装(SetPos)
前面我们说了COORD相关的知识,但是每一次光标定位我们都需要获得句柄、坐标更改、设置坐标三步,这样代码会显得冗杂
我们不妨建立一个函数SetPos,将如上步骤都包含在内,我们只需要将坐标传进去就可以了,不需要返回值,如下:
void SetPos(int x, int y)
{
//获得设备句柄
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
//根据句柄设置光标位置
COORD pos = { x,y };
SetConsoleCursorPosition(handle, pos);
}
贪吃蛇欢迎信息界面(WelComeToGame)
在游戏开始之前,我们可以先打印一些欢迎信息,并告知一些规则
而如果要实现这个效果的话,我们需要 system("pause") 与刚创建好的 SetPos 函数,每切换一个界面,我们就用 system(“ cls ”)清屏就可以了
代码如下:
//打印欢迎界面
void WelcomeToGame()
{
SetPos(35, 12);
printf("欢迎来到贪吃蛇小游戏\n");
SetPos(38, 20);
system("pause");
system("cls");
SetPos(28, 10);
printf("用↑↓←→来控制蛇的移动,A键是加速,D键是减速");
SetPos(28, 11);
printf("加速能得到更高的分数");
SetPos(28, 12);
printf("按空格键可以暂停,按Esc键可以退出游戏");
SetPos(38, 20);
system("pause");
system("cls");
}
我们每打印一句话之后,就可以考虑再次换位,然后打印下一句话,代码效果如下:
本地化函数 (setlocale)
C语言最初是英文的,但是全世界的人们都要用的话,仅仅是英文就不够用了,不说法国、意大利之类的国家有很多其他符号,就我们中国,光汉字都有10万多个,一个字节大小最多也就256,根本无法涵盖
所以,我们在创建项目之前,我们需要先让编辑器适配本地的信息,就比如我们接下来打印墙体、食物、蛇身所需要的宽字符就需要本地化
而本地化我们仅需要引头文件 #include <locale.h>,然后我们来看看这个函数的语法
char* setlocale (int category, const char* locale);
如上我们可以看到,该函数的第一个参数需要的是上面5个中的一个
这里面有改变时间的,有改变金钱单位的。而我们现在需要的,是全部都改变,所以我们就选择第一个 LC_ALL(全部都改变)
第二个参数如下
我们会看到参数有两种,“C” 是C语言默认环境,而 “ ” 则是适配本地环境
综上,我们本地化的代码如下:
#include<locale.h>
//引头文件
setlocale(LC_ALL, "");
//本地化
贪吃蛇地图绘制(CreatMap)
在绘制地图之前,我们需要先知道的是,编辑器的横坐标的长度是纵坐标的两倍
而如果我们要打印墙体且不想让墙体看起来很扁的话,我们就需要用到宽字符,如下:
printf("ab\n");
printf("中\n");
wprintf(L"%lc\n", L'□');
我们会发现,宽字符□的大小是单一一个字母的两倍,而我们如果要打印墙体或者蛇身的话,我们需要用到宽字符,不然会显得蛇很扁,看起来很别扭
接下来我们就来打印墙体
我们的思路是:先用SetPos函数找到对应的位置,然后用for循环来循环打印宽字符作为墙体
但是考虑到每一次打印墙体都需要写 L'对应符号',所以我们可以定义一个宏,这样即使我们以后想让墙换一个符号的话也方便,如下:
#define WALL L'□'
我们就打印一个 58*27 的地图吧,墙体打印代码如下:
//绘制地图
void CreatMap()
{
int i = 0;
//上
SetPos(0, 0);
for (i = 0; i <= 56; i+=2)
{
wprintf(L"%lc", WALL);
}
//下
SetPos(0, 26);
for (i = 0; i <= 56; i += 2)
{
wprintf(L"%lc", WALL);
}
//左
for (i = 1; i <= 25; i++)
{
SetPos(0, i);
wprintf(L"%lc", WALL);
}
//右
for (i = 1; i <= 25; i++)
{
SetPos(56, i);
wprintf(L"%lc", WALL);
}
}
链表定义蛇身
对于蛇身,我们需要在头文件中定义一个链表,如下:
//蛇身结点的定义
typedef struct SnakeNode
{
int x;
int y;
struct SnakeNode* next;
}SnakeNode, * pSnakeNode;
结构体维护贪吃蛇游戏
一个贪吃蛇游戏,需要考虑蛇本身、食物、方向、当前状态等等
我们将这些要素全部放在一个结构体里面,将其 typedef 为 Snake,通过这个结构体我们就能找到全部变量
而其中的状态和方向又分为上下左右、正常、撞到自己,撞到墙、主动退出游戏等等
对此,我们可以考虑使用枚举,一方面是因为#define定义的宏要定义多个太麻烦,另一方面是因为使用枚举方便调试,而且这种情况下使用枚举确实会好一些
代码如下:
enum GAME_STATUS
{
OK=1,
ESC,
KILL_BY_WALL,
KILL_BY_SELF
};
enum DIRECTION
{
UP=1,
DOWN,
LEFT,
RIGHT
};
//贪吃蛇
typedef struct Snake
{
pSnakeNode pSnake;//维护整条蛇的指针
pSnakeNode pFood;//指向食物的指针
int score;//当前积累的分数
int FoodWeight;//一个食物的分数
int SleepTime;//蛇休眠的时间,时间越短,蛇的速度越快
enum GAME_STATUS status;//游戏当前的状态
enum DIRECTION dir;//蛇当前走的方向
}Snake,*pSnake;
游戏界面提示信息打印(PrintHelpInfo)
我们进入游戏之后会发现游戏旁边的界面有点空,我们可以打印一些提示信息上去
这个环节无非就是 SetPos 函数定位,接着 printf 打印信息,这里我就直接给代码了:
void PrintHelpInfo()
{
SetPos(60, 12);
printf("1.不能穿墙,不能咬到自己");
SetPos(60, 14);
printf("2.用 ↑.↓.←.→ 来控制蛇的移动");
SetPos(60, 16);
printf("3.A键是加速,D键是减速");
SetPos(60, 18);
printf("4.按空格键可以暂停,按Esc键可以退出游戏");
SetPos(60, 20);
printf("嘉鑫版");
}
初始化蛇(InitSnake)
我们可以先创建一条初始长度为5的蛇,那么我们需要先用 for 循环 malloc 5个节点,然后依次头插,形成一条链表。
接下来,我们先假设开局就是如上所示。
malloc申请蛇节点
先将我们的蛇的结构体传过去,然后将头节点置为空,如下:
//Snake.h
typedef struct SnakeNode
{
int x;
int y;
struct SnakeNode* next;
}SnakeNode, * pSnakeNode;
typedef struct Snake
{
pSnakeNode pSnake;//维护整条蛇的指针
pSnakeNode pFood;//指向食物的指针
int score;//当前积累的分数
int FoodWeight;//一个食物的分数
int SleepTime;//蛇休眠的时间,时间越短,蛇的速度越快
enum GAME_STATUS status;//游戏当前的状态
enum DIRECTION dir;//蛇当前走的方向
}Snake,*pSnake;
//Snake.c
void InitSnake(pSnake ps)
{
//创建5个蛇身的结点
ps->pSnake = NULL;
}
接着 for 循环 malloc 5个类型为SnakeNode的节点,而该结构体内的 X 可以先初始化成一个(具体的值 + 2 * i),Y 可以直接初始化成一个具体的值,因为如上图所示,我们初始长度的蛇的 Y 坐标都相同
当然,你也可以将这个值用 #define 定义为一个宏,方便以后修改
代码如下:
#define POS_X 24
#define POS_Y 5
void InitSnake(pSnake ps)
{
//创建5个蛇身的结点
ps->pSnake = NULL;
int i = 0;
pSnakeNode cur = NULL;
for (i = 0; i < 5; i++)
{
cur = (pSnakeNode)malloc(sizeof(SnakeNode));
//申请节点
if (cur == NULL)
{
//判断开辟空间是否成功
perror("malloc fail!");
return;
}
//初始化刚申请的节点的成员
cur->x = POS_X + 2 * i;
cur->y = POS_Y;
cur->next = NULL;
//头插法
if (ps->pSnake == NULL)
{
ps->pSnake = cur;
//判断没有节点的情况
}
else
{
cur->next = ps->pSnake;
ps->pSnake = cur;
}
}
}
这里我再来讲一讲头插代码
如上,cur 指向的是新开辟的节点
我们先让 cur 指向链表的头节点,然后再将 ps->pSnake 定义为新的头
打印蛇身与其他信息的初始化
打印蛇身相对简单,我们的思路就是:先通过 SetPos 函数找到对应节点,然后宽字符打印
考虑到后续代码中还有蛇身要打印,所以这里就将蛇身的符号用 #define 定义起来
#define BODY L'●'
//打印蛇身
cur = ps->pSnake;
//将cur定义为新的头
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
//寻找下一个要打印的节点,直到为空
}
而我们其他信息的初始化就相对轻松一些,代码如下:
//贪吃蛇其他信息初始化
ps->dir = RIGHT;
ps->FoodWeight = 10;
ps->pFood = NULL;
ps->score = 0;
ps->SleepTime = 200;
ps->status = OK;
综上,我们先申请了节点,接着打印蛇身,最后将其他信息给初始化了
初始化蛇的总代码如下:
void InitSnake(pSnake ps)
{
//创建5个蛇身的结点
ps->pSnake = NULL;
int i = 0;
pSnakeNode cur = NULL;
for (i = 0; i < 5; i++)
{
cur = (pSnakeNode)malloc(sizeof(SnakeNode));
if (cur == NULL)
{
perror("malloc fail!");
return;
}
cur->x = POS_X + 2 * i;
cur->y = POS_Y;
cur->next = NULL;
//头插法
if (ps->pSnake == NULL)
{
ps->pSnake = cur;
}
else
{
cur->next = ps->pSnake;
ps->pSnake = cur;
}
}
//打印蛇身
cur = ps->pSnake;
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
}
//贪吃蛇其他信息初始化
ps->dir = RIGHT;
ps->FoodWeight = 10;
ps->pFood = NULL;
ps->score = 0;
ps->SleepTime = 200;
ps->status = OK;
}
初始化食物(CreateFood)
初始化了蛇,接下来我们就该初始化食物了
生成食物坐标
贪吃蛇中的食物随机出现在地图中的任意位置(墙和蛇身除外),我们可以用 rand 函数设置随机,接着用time(时间戳)改变种子( srand(unsigned seed) ),也就是 srand( (unsigned) time (NULL) )
而要使食物不出现在墙上的话,先来讨论 x 坐标(注意,我们的地图大小为 58*27),但是x从0开始,所以食物的 x 坐标的范围就是2~54
我们可以先将rand的结果%53,得到的就是 0~52 以内的随机数,再将这个结果+2,得到的就是2~54以内的随机值,y同理,如下:
x = rand() % 53 + 2;
y = rand() % 24 + 1;
但是这时发现了一个问题,因为一个字符在 x 轴上的大小是 y 轴的一半,而且无论是墙体,蛇身还是食物,打印的都是宽字符
这也就意味着,我们随机出来的 x 必须是偶数,不然就会出现下面的场景
我们没有办法让 rand 函数每次的随机数都是偶数,但是我们可以设置一个循环。如果这次随机出来的 x 不是一个偶数,那我们就让其再随机生成一次,而判断偶数就只需要%2看等不等于0就行了
代码如下:
do
{
x = rand() % 53 + 2;
y = rand() % 24 + 1;
} while (x % 2 != 0);
接着我们需要再判断一下,随机出来的坐标在不在蛇身上
这时我们只需要用一个while循环,创建一个cur指针指向头节点,每次查看完之后向后走一个节点,当cur指向空时就停下来
每到一个节点就拿随机生成的 x、y 坐标和节点内的 x、y 坐标进行比较,如果有相同的,就再随机生成一次食物坐标
int x = 0, y = 0;
again:
do
{
x = rand() % 53 + 2;
y = rand() % 24 + 1;
} while (x % 2 != 0);
//判断坐标在不在蛇身上,与每个节点作比较
pSnakeNode cur = ps->pSnake;
while (cur)
{
if (x == cur->x && y == cur->y)
{
goto again;
}
cur = cur->next;
}
在这里我们可以使用goto语句,面对循环嵌套之类的情况使用goto语句会方便很多
创建并打印食物
我们可以这么理解,食物就是蛇身的一部分,只不过不跟蛇身连在一起,当玩家吃到食物之后,就直接将食物头插在蛇身上,唯一的区别就是打印的时候,用到宽字符不是一个符号而已
所以同样的,我们也是用 malloc 开辟一块空间,初始化,最后将其打印出来
食物的话我们可以跟蛇身、墙体一样用 #define 定义一个宏
#define FOOD L'★'
//创建食物
//开辟食物的空间
pSnakeNode pFood = (pSnakeNode)malloc(sizeof(SnakeNode));
//判断是否开辟成功
if (pFood == NULL)
{
perror("malloc fail!");
return;
}
//初始化
pFood->x = x;
pFood->y = y;
ps->pFood = pFood;
//打印食物
SetPos(x, y);
wprintf(L"%lc", FOOD);
综上,初始化食物这段代码如下:
void CreateFood(pSnake ps)
{
int x = 0, y = 0;
again:
do
{
x = rand() % 53 + 2;
y = rand() % 24 + 1;
} while (x % 2 != 0);
//判断坐标在不在蛇身上,与每个节点作比较
pSnakeNode cur = ps->pSnake;
while (cur)
{
if (x == cur->x && y == cur->y)
{
goto again;
}
cur = cur->next;
}
//创建食物
pSnakeNode pFood = (pSnakeNode)malloc(sizeof(SnakeNode));
if (pFood == NULL)
{
perror("malloc fail!");
return;
}
pFood->x = x;
pFood->y = y;
ps->pFood = pFood;
SetPos(x, y);
wprintf(L"%lc", FOOD);
}
GameStart 函数代码
综上,游戏开始前我们调整了控制台,隐藏了光标,打印了地图,初始化了蛇和食物以及其他游戏信息,代码如下:
void GameStart(pSnake ps)
{
//控制控制台的信息,窗口大小,窗口名
system("mode con cols=100 lines=30");
system("title 贪吃蛇");
//隐藏光标
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);//获得句柄
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(handle, &CursorInfo);//获取控制台光标信息
CursorInfo.bVisible = false;//隐藏光标
SetConsoleCursorInfo(handle, &CursorInfo);
//打印欢迎信息
WelcomeToGame();
//绘制地图
CreatMap();
//打印提示信息
PrintHelpInfo();
//初始化蛇
InitSnake(ps);
//创建食物
CreateFood(ps);
}
贪吃蛇游玩阶段(GameRun)
按键判断
首先,我们需要知道每个键的虚拟键值,如下:
https://learn.microsoft.com/zh-cn/windows/win32/inputdev/virtual-key-codes
游戏运行时,我们需要判断哪个键被按过——加速、减速、上下左右、暂停、退出等
我们可以这么做,上下左右按键判断时,我们只需改变维护整个贪吃蛇结构体里的方向状态就行了
而其他的按键判断我们就设置一个 do...while 循环,条件就是判断状态是否为OK,如果不为OK,就退出循环,游戏结束
状态和方向的设定,以及维护整个贪吃蛇的结构体如下:
enum GAME_STATUS
{
OK=1,
ESC,
KILL_BY_WALL,
KILL_BY_SELF
};
enum DIRECTION
{
UP=1,
DOWN,
LEFT,
RIGHT
};
//贪吃蛇
typedef struct Snake
{
pSnakeNode pSnake;//维护整条蛇的指针
pSnakeNode pFood;//指向食物的指针
int score;//当前积累的分数
int FoodWeight;//一个食物的分数
int SleepTime;//蛇休眠的时间,时间越短,蛇的速度越快
enum GAME_STATUS status;//游戏当前的状态
enum DIRECTION dir;//蛇当前走的方向
}Snake,*pSnake;
因为我们一次就只能按一个按键,所以我们可以用 if...else if...else 语句来进行判断
上下左右
如果为上下左右,那我们还需判断一下:
当方向向上/下的时候,不能按下向下/上的按键
当方向向左/右的时候,不能按下向右/左的按键
代码如下:
void GameRun(pSnake ps)
{
do
{
//检测按键
//上、下、左、右
if (KEY_PRESS(VK_UP) && ps->dir != DOWN)
{
ps->dir = 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;
}
} while (ps->status == OK);
}
可能有人会疑惑,单单改变一个方向的状态,就真的能让蛇的方向改变吗?当然不能
我们改变状态是为了在后续贪吃蛇行动时,我们可以通过这个状态进行 switch...case 操作决定下一个节点是在贪吃蛇头部的哪一个方向
Esc
而我们如果要判断退出(Esc)的话,我们只需要改变 ps->status 就行了,这样子的话游戏进行下来,当再次进入循环条件判断时,状态不为 OK,就会退出循环,游戏自然也会结束
else if (KEY_PRESS(VK_ESCAPE))
{
ps->status = ESC;
break;
}
空格键
而如果按下的是空格键的话,我们可以建立一个函数 pause,函数的内容就是死循环地 Sleep(时间),只有当玩家再次按下空格键或者 Esc 时,才会退出循环
void pause(pSnake ps)
{
while (1)
{
Sleep(1000);
if (KEY_PRESS(VK_SPACE))
{
break;
}
else if (KEY_PRESS(VK_ESCAPE))
{
ps->status = ESC;
break;
}
}
}
else if (KEY_PRESS(VK_SPACE))
{
pause(ps);
}
除了以上的键位之外,我们还有加速和减速功能需要实现
加速减速
这里不太推荐 F1~F0 的键位作为加速或减速的键位,这是因为现在的电脑 F1~F0 键已经不像早期的电脑那么纯粹了,上面除了原有的功能之外,还有了调整亮度、声音、截屏等功能,需要手动按 Fn 键进行切换,如果别人没有注意到的话,很可能会出现按了加速减速却没反应的情况,这是我们需要避免的
我们可以用 A 代表加速,D 代表减速,两个键的虚拟键值分别是 0X41 和 0X44
贪吃蛇游戏的原理就是走一步休眠一下,休眠的时间越短,视觉上看来蛇的速度就越快。所以我们判断到玩家按下了 A 键时,我们就将休眠时间调整得短一点,按下 D 键同理
同时,因为速度快了,我们可以令每一个食物的分值上升,同时设定一个速度的上限
代码如下:
else if (KEY_PRESS(0X41))
{
if (ps->SleepTime >= 80)
{
ps->SleepTime -= 30;
ps->FoodWeight += 2;
}
}
else if (KEY_PRESS(0X44))
{
if (ps->FoodWeight > 2)
{
ps->SleepTime += 30;
ps->FoodWeight -= 2;
}
}
蛇走一步(SnakeMove)
下一个节点的创立 & 方向判断
贪吃蛇走一步的原理是:
将贪吃蛇要走的下一个节点找出来,头插该节点
如若下一个节点是食物,那么就头插食物就够了
如若下一个节点不是食物,那么我们就在头插下一个节点的同时,删除尾节点并打印成空格
所以我们在方向判断之前,我们还需要 malloc 下一个节点,内部的 x、y 通过方向来初始化
pSnakeNode pNext = (pSnakeNode)malloc(sizeof(SnakeNode));
if (pNext == NULL)
{
perror("malloc fail!!");
return;
}
pNext->next = NULL;
在进入该函数之前,我们已经判断过方向了,而我们是用枚举类型定义的方向,所以在这里我们可以用 switch...case 语句判断贪吃蛇要走的下一个节点在哪里
比如方向向左,那下一个节点的坐标,就在相对贪吃蛇头节点 x-2,y 不变的位置
比如向上,那么下一个节点就在坐标,就在相对贪吃蛇头节点 x 不变,y-1 的位置
向右向下同理
代码如下:
switch (ps->dir)
{
case UP:
pNext->x = ps->pSnake->x;
pNext->y = ps->pSnake->y - 1;
break;
case DOWN:
pNext->x = ps->pSnake->x;
pNext->y = ps->pSnake->y + 1;
break;
case LEFT:
pNext->x = ps->pSnake->x - 2;
pNext->y = ps->pSnake->y;
break;
case RIGHT:
pNext->x = ps->pSnake->x + 2;
pNext->y = ps->pSnake->y;
break;
}
判断下一个节点是不是食物(NextIsFood)
这个函数的实现比较简单,我们直接拿 食物的坐标 和 贪吃蛇头节点的下一个节点的坐标 比较一下,如果相等,那么下一个节点就是食物,不相等,那就不是
代码如下:
int NextIsFood(pSnake ps, pSnakeNode pNext)
{
if (ps->pFood->x == pNext->x && ps->pFood->y == pNext->y)
{
return 1;
}
else
{
return 0;
}
}
是食物就吃掉食物(EatFood)
如果下一个节点是食物的话,我们先将下一个节点头插到贪吃蛇上,然后打印蛇身
同时,我们之前定义食物的时候还 malloc 了一块空间,我们既然拿下一个节点的空间头插到贪吃蛇上面,那我们就应该将原本食物的节点给销毁(free)
吃掉了食物之后,我们原先在地图上的食物就被覆盖了,那么这时我们就应该再创建一个食物
同时,我们吃掉了一个食物之后,我们的分数也应该变高,就让原先的分数加上一个食物的分数
代码如下:
void EatFood(pSnake ps, pSnakeNode pNext)
{
//头插
pNext->next = ps->pSnake;
ps->pSnake = pNext;
pSnakeNode cur = ps->pSnake;
//打印蛇
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
//向后走一个位置
}
ps->score += ps->FoodWeight;
//释放旧的食物
free(ps->pFood);
//新建食物
CreateFood(ps);
}
不是食物就走一步(NotEatFood)
当下一个节点不是食物的时候,我们就先将下一个节点头插到贪吃蛇上面
同时我们需要知道,本来贪吃蛇是已经被打印出来了的,所以我们只需要将新的头节点打印出来,同时将尾节点打印成空格,我们就能在视觉上实现贪吃蛇向后走一步的效果
至于找到尾节点,我们可以用一个 while 循环,定义一个 cur 指针,让 cur 指针遍历一遍链表,当cur->next 指向空的时候,循环结束,此时我们的 cur 指针指向的就是尾节点
然后我们 SetPos 到这个位置之后打印两个空格,注意,是两个空格,因为 x 的大小是 y 的两倍
代码如下:
void NotEatFood(pSnake ps, pSnakeNode pNext)
{
//头插
pNext->next = ps->pSnake;
ps->pSnake = pNext;
//释放尾结点
//顺便打印尾节点
pSnakeNode cur = ps->pSnake;
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
//找尾节点
while (cur->next->next)
{
cur = cur->next;
}
//将尾节点置空 打印 ' '
SetPos(cur->next->x, cur->next->y);
printf(" ");//两个空格!!!
free(cur->next);
cur->next = NULL;
}
撞到墙结束游戏(KillByWall)
判断蛇是否撞到墙,我们只需要将贪吃蛇的头节点是否在墙壁所圈定的范围之内,或者是在墙上,如果不在这个范围内,那就证明蛇已经撞到墙了
接着,我们需要将游戏的状态更改为 KILL_BY_WALL
当蛇向后走了一步时,判断到状态不为 OK,就会跳出循环,游戏结束
我们打印的地图的大小是 58*27,但是坐标是从(0,0)开始的,所以只要 x 不在 2~55 这个范围内,y 不在 1~26 这个范围内,那么就说明蛇撞到墙了
代码如下:
void KillByWall(pSnake ps)
{
if (ps->pSnake->x == 0 ||
ps->pSnake->x == 56 ||
ps->pSnake->y == 0 ||
ps->pSnake->y == 26)
{
ps->status = KILL_BY_WALL;
}
}
撞到自己结束游戏(KillBySelf)
我们要判断蛇是否会撞到自己,我们只需要将头节点和蛇身的每一个坐标一一比对,当发现有相同的时候,就说明蛇已经咬到自己了
但是蛇的前三个节点没有相撞的可能性,如下图
所以我们可以从第四个节点开始判断,至于如何找到第四个节点,我们只需要将创建一个新指针,让这个新指针 = 头指针->next->next->next,这时,这个新指针指向的就是第四个节点
当我们发现头节点的 x、y 和第四个结点之后的节点的 x、y 的值相同的时候,我们就可以修改状态为 KILL_BY_SELF
代码如下:
void KillBySelf(pSnake ps)
{
pSnakeNode cur = ps->pSnake->next->next->next;
while (cur)
{
if (cur->x == ps->pSnake->x && cur->y == ps->pSnake->y)
{
ps->status = KILL_BY_SELF;
return;
}
cur = cur->next;
}
}
整合 SnakeMove 函数
我们在令蛇向后走完了一步之后,需要让贪吃蛇睡眠一段时间,而这段时间我们设置在了维护整个贪吃蛇的结构体里,并将其初始化为 200毫秒 ,具体的初始化内容各位可以看回 初始化蛇(InitSnake) 部分
综上,GameRun函数 代码如下:
void GameRun(pSnake ps)
{
do
{
SetPos(62, 9);
printf("总分:%5d\n", ps->score);
SetPos(62, 10);
printf("食物的分值:%02d\n", ps->FoodWeight);
//检测按键
//上、下、左、右、ESC、空格、F3、F4
if (KEY_PRESS(VK_UP) && ps->dir != DOWN)
{
ps->dir = 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;
break;
}
else if (KEY_PRESS(VK_SPACE))
{
pause(ps);
}
else if (KEY_PRESS(0X41))
{
if (ps->SleepTime >= 80)
{
ps->SleepTime -= 30;
ps->FoodWeight += 2;
}
}
else if (KEY_PRESS(0X44))
{
if (ps->FoodWeight > 2)
{
ps->SleepTime += 30;
ps->FoodWeight -= 2;
}
}
//走一步
SnakeMove(ps);
//睡眠一下
Sleep(ps->SleepTime);
} while (ps->status == OK);
}
贪吃蛇收尾阶段(GameEnd)
判断状态 & 打开原神官网
由于我们的状态是用枚举类型定义的,所以我们可以用 switch...case 语句来进行分类讨论
当状态为 ESC 时,我们就打印提示信息并 break
当状态为 KILL_BY_WALL 或 KILL_BY_SELF 时,我们就浅浅嘲讽一下,比如打开原神官网
打开网站可以使用 system ( " start + 网站 " );
代码如下:
SetPos(15, 12);
switch (ps->status)
{
case ESC:
printf("别啊,怎么就不玩了,不会是太菜了吧(滑稽)");
break;
case KILL_BY_SELF:
printf("即将为您打开您的最爱!!!");
SetPos(15, 13);
printf("即将为您打开您的最爱!!!");
SetPos(15, 14);
printf("即将为您打开您的最爱!!!");
for (int i = 3; i >=0; i--)
{
SetPos(28, 15);
printf("%d", i);
Sleep(1000);
}
system("start https://ys.mihoyo.com/");
break;
case KILL_BY_WALL:
printf("即将为您打开您的最爱!!!");
SetPos(15, 13);
printf("即将为您打开您的最爱!!!");
SetPos(15, 14);
printf("即将为您打开您的最爱!!!");
for (int i = 3; i > 0; i--)
{
SetPos(28, 15);
printf("%d", i);
Sleep(1000);
}
system("start https://ys.mihoyo.com/");
break;
}
释放资源
游戏结束了之后,我们需要释放蛇的空间的同时,还要释放食物的空间(因为两者都是malloc开辟)
至于删除蛇身,我们可以定义两个指针,一个指向要删除的节点,一个指向下一个节点
这是因为如果我们将该节点的空间释放掉之后,我们就找不到下一个节点了,所以我们才需要两个节点,而循环的条件就是当指针 del 指向 NULL 的时候,循环停止
在释放完之后,不忘释放食物的空间
代码如下:
//释放资源
pSnakeNode cur = ps->pSnake;
pSnakeNode del = NULL;
while (cur)
{
del = cur;
cur = cur->next;
free(del);
}
SetPos(0, 28);
free(ps->pFood);
ps = NULL;
代码总和
void GameEnd(pSnake ps)
{
SetPos(15, 12);
switch (ps->status)
{
case ESC:
printf("别啊,怎么就不玩了,不会是太菜了吧(滑稽)");
break;
case KILL_BY_SELF:
printf("即将为您打开您的最爱!!!");
SetPos(15, 13);
printf("即将为您打开您的最爱!!!");
SetPos(15, 14);
printf("即将为您打开您的最爱!!!");
for (int i = 3; i >=0; i--)
{
SetPos(28, 15);
printf("%d", i);
Sleep(1000);
}
system("start https://ys.mihoyo.com/");
break;
case KILL_BY_WALL:
printf("即将为您打开您的最爱!!!");
SetPos(15, 13);
printf("即将为您打开您的最爱!!!");
SetPos(15, 14);
printf("即将为您打开您的最爱!!!");
for (int i = 3; i > 0; i--)
{
SetPos(28, 15);
printf("%d", i);
Sleep(1000);
}
system("start https://ys.mihoyo.com/");
break;
}
//释放资源
pSnakeNode cur = ps->pSnake;
pSnakeNode del = NULL;
while (cur)
{
del = cur;
cur = cur->next;
free(del);
}
SetPos(0, 28);
free(ps->pFood);
ps = NULL;
}
背景音乐设置
首先我们需要引头文件
#include <mmsystem.h>//导入声音头文件
#pragma comment(lib,"Winmm.lib")
接着我们需要一个wav类型的音频,将其放在 debug 文件下
最后在main函数内部,我们插入下方代码:
PlaySound(TEXT("zaoan.wav"), NULL, SND_FILENAME | SND_ASYNC | SND_LOOP);
//zaoan要替换成音频名字
PlaySound(TEXT("音频名字.wav"), NULL, SND_FILENAME | SND_ASYNC | SND_LOOP);
结语&总代码
到这里,我们的贪吃蛇就完结,撒花啦!
各位如果要看总代码的话,可以点开下方我的 gitee
https://gitee.com/qingchen_zhaomu/daily-code-collection/tree/master/test_2024_1_26/test_2024_1_26
如果各位喜欢的话,希望可以多多支持!!!