C语言——贪吃蛇(详解)

目录

功能介绍

作者写的游戏提取

Win32 API 相关知识介绍

控制台程序

COORD

GetStdHandle

GetConsoleCursorInfo

​编辑

SetConsoleCursorInfo

SetConsoleCursorPosition

GetAsyncKeyState

贪吃蛇准备阶段(GameStart)

定位函数包装(SetPos)

贪吃蛇欢迎信息界面(WelComeToGame)

本地化函数 (setlocale)

贪吃蛇地图绘制(CreatMap)

链表定义蛇身

结构体维护贪吃蛇游戏

游戏界面提示信息打印(PrintHelpInfo)

初始化蛇(InitSnake)

malloc申请蛇节点

打印蛇身与其他信息的初始化

初始化食物(CreateFood)

生成食物坐标

创建并打印食物

GameStart 函数代码

贪吃蛇游玩阶段(GameRun)

按键判断

上下左右

Esc

空格键

加速减速

蛇走一步(SnakeMove)

下一个节点的创立 & 方向判断

判断下一个节点是不是食物(NextIsFood)

是食物就吃掉食物(EatFood)

不是食物就走一步(NotEatFood)

撞到墙结束游戏(KillByWall)

撞到自己结束游戏(KillBySelf)

整合 SnakeMove 函数

贪吃蛇收尾阶段(GameEnd)

判断状态 & 打开原神官网

释放资源

代码总和

背景音乐设置

结语&总代码


贪吃蛇,一个相当经典的小游戏,相信各位或多或少都玩过或是听过。而如果要实现这个小游戏的话,我们就需要熟悉 结构体、指针 以及 链表 的相关知识

功能介绍

  1. 背景音乐播放
  2. 贪吃蛇地图的打印
  3. 吃食物边长
  4. 贪吃蛇的移动
  5. 计算得分
  6. 撞墙与撞自身结束游戏并打开原神相关网站
  7. 贪吃蛇的加速减速
  8. 暂停游戏

作者写的游戏提取

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

COORDWin32 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

如果各位喜欢的话,希望可以多多支持!!!

  • 26
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值