目录

C语言贪吃蛇游戏万字解读超详细

C语言:贪吃蛇游戏(万字解读超详细)

目录


前言

贪吃蛇这个游戏应该我们小时候都玩过吧

我相信能够点进这个博客的应该都很熟悉这个游戏了

如果对这个游戏不熟悉的可以网上查查怎么玩,这样才能对这个游戏有个清晰的认知

https://i-blog.csdnimg.cn/blog_migrate/b599d12e76542fbe5ffcca97e939fb01.png

https://i-blog.csdnimg.cn/blog_migrate/54915d6b41e9584797cc4bc35dff1f8a.png

https://i-blog.csdnimg.cn/blog_migrate/ad2a265dc3823789363eb6610559d19b.png

该篇博客需要拥有C语言基础(对指针熟悉、会链表的使用)

下面我们需要先学习一些C语言之外的东西

认真坚持看完,我相信你一定能完成这个接近500行的C语言贪吃蛇项目

控制台的修改

首先我们需要学几个命令

我们的windows系统如果输入win + R可以打开一个窗口,然后输入cmd是可以打开一个控制台窗口的,这个控制台可以输入一些命令来控制我们的电脑,其实从本质上来说我们在鼠标上点击这些按键其实就是一串串命令

修改默认终端程序

我们首先需要改变我们的控制台,如果不修改好我们的控制台会影响

本人是在vs2022的环境下运行的,有关控制台的修改如下:

先随便运行一次,调出控制台

https://i-blog.csdnimg.cn/blog_migrate/2e9ec15f579dd96a36bf535c1e258cc0.png

点击设置

https://i-blog.csdnimg.cn/blog_migrate/0ddf6952a21a69908c73f7e3b3a39710.png

改成如上图这样

如果不行改成下图这样

https://i-blog.csdnimg.cn/blog_migrate/23ba50d013f8630145fa2f1db5c2b6eb.png

https://i-blog.csdnimg.cn/blog_migrate/510c96c37ff7ffacd5dd4722e7838d27.png 如果改成这样的控制台就说明修改成功了

如果有显示不出文字的问题,可以尝试修改一下背景版的颜色

修改控制台窗口大小

刚刚说了win+R,输入cmd可以打开控制台

如果我们在控制台里输入这么一串命令

mode con cols=100 lines=30

我们会发现我们控制台的大小就发生了变化

会一些英语的你也许一眼就看出来了,cols是列,lines是行,所以这串命令的意思就是把列的值修改为100,把行的值修改为30

那么我们在我们的windows控制台上修改了,如何在我们编译的环境下通过代码实现呢?

system函数

在使用函数前,不要忘记加上它的头文件

#include <stdlib.h>

https://i-blog.csdnimg.cn/blog_migrate/95611dbf0df48895346dd97d794abd71.png

看不懂也没关系,我来给你口头解释一下

我们需要在system函数里传的参数是一串字符串

那么我们只需要在我们system函数里加入刚刚修改控制台窗口大小的那串命令,就可以将我们vs的控制台窗口大小修改了

system("mode con cols=100 lines=30");

修改控制台窗口名字

为了美观我们的游戏界面,我们当然要修改一下名字了

如果在windows控制台下输入

title 贪吃蛇

https://i-blog.csdnimg.cn/blog_migrate/52656bf3a6ff0816ab1c6b344fa48814.png

那么会由上图变成下图

https://i-blog.csdnimg.cn/blog_migrate/c8e491373cba3008427d7051667e079c.png

所以根据刚刚讲的system函数,我们把一样的在我们代码里这么写

system("title 贪吃蛇");

就可以让我们的控制台窗口更加美观了

隐藏控制台光标

https://i-blog.csdnimg.cn/blog_migrate/2506f00ba2213120819890db5f3723cb.png

如果我们控制台在输入东西的时候一直有一个光标闪动,会极度影响我们贪吃蛇游戏在控制台运行时的美观,所以我们需要将它隐藏

GetStdHandle函数

该函数的的作用是从一个特定的标准设备(标准输入、标准输出、标准错误)中取得一个句柄(用来标识不同设备的数值)

https://i-blog.csdnimg.cn/blog_migrate/e4e05b77ec343811b7ab2d2482471d1f.png

该函数得返回值为HANDLE,参数只有一个,需要三选一

https://i-blog.csdnimg.cn/blog_migrate/a4bfffc3b7b14e9392984a1e8f23689c.png

我们应该选择第二个STD_OUTPUT_HANDLE

获取标准输出的句柄()

我们需要定义一个HANDLE的变量来接收

HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);

我们定义了一个名为houtput的变量用来接收句柄

因为该函数就是获取句柄,返回句柄出去需要我们接收,稍后需要用该句柄使用其他函数配合完成控制台的修改

如果想详细了解该函数,该函数详细说明的链接:

GetConsoleCursorInfo函数

该函数的作用是检索屏幕缓冲区的游标大小和可见性的信息

https://i-blog.csdnimg.cn/blog_migrate/fa71bf63a20d362d44f532994e5e61a9.png

该函数需要两个参数,第一个参数为一个句柄,第二个参数是PCONSOLE_CURSOR_INFO

前面我们的GetStdHandle获得的句柄就在这能够用上了

PCONSOLE_CURSOR_INFO是一个指针类型,指向CONSOLE_CURSOR_INFO类型的指针

那么CONSOLE_CURSOR_INFO又是什么类型?

https://i-blog.csdnimg.cn/blog_migrate/18c7d447e568942731d93e835b4120b6.png

它是一个自定义类型,第一个名字为dwSize的值是一个光标所占一格的大小,第二个bVisible则是光标的可见度

若我们先定义一个CONSOLE_CURSOR_INFO类型的变量,名字为CursorInfo

CONSOLE_CURSOR_INFO CursorInfo;

那么我们在使用GetConsoleCursorInfo函数的时候,第一个参数传我们刚刚接收句柄的变量houtput,第二个参数则传CursorInfo的地址即可

GetConsoleCursorInfo(houtput, &CursorInfo);

这样我们就将我们控制台里一个光标所占一格的大小和光标的可见度都放到了CursorInfo这个变量里

HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(houtput, &CursorInfo);
printf("%d", CursorInfo.dwSize);

若我们打印这个CursorInfo光标所占一格的大小,结果为:

https://i-blog.csdnimg.cn/blog_migrate/9a882bccfcec06c5f4afce63b63f6550.png

所以由此可以得出结论:

1.我们的GetConsoleCursorInfo函数确实将我们控制台的光标所占一格的大小、光标的可见度,这两个属性赋值给了我们的自定义类型变量CursorInfo,我们能够从中获取

2.我们控制台初始情况下光标所占一格的大小为25,100是满格的话,25就是25%

再来看看我们控制台的光标

https://i-blog.csdnimg.cn/blog_migrate/a05d2c0dcf2648e494224920a599ddda.png

看起来确实像是占了一格的25%

若我们这样改

CursorInfo.dwSize = 50;

改完并且设置完后(设置方法在下一个函数讲解),光标变成了这样

https://i-blog.csdnimg.cn/blog_migrate/77500aae41d3c76cf2d5e7fe9e4fcd2a.png

CursorInfo.dwSize = 100;

这样的代码光标又是如下:

https://i-blog.csdnimg.cn/blog_migrate/cb160bd758be74d4c0570e13a2027ba4.png

所以可以看到我们确实将光标改了

所以接下来我们只需要使用CursorInfo的第二个bVisible调整控制台的可见度即可,代码如下:

CursorInfo.bVisible = false;
//或者CursorInfo.bVisible = 0;

因为在官方给的解释中 bVisible的类型是bool类型,所以这里使用false可能会更标准一点,但是这两种写法都会隐藏掉光标,所以可以看个人喜好写

如果是使用第一种写法,那么不要忘记加上bool类型的头文件

#include <stdbool.h>

这样我们的控制台的光标就没了

https://i-blog.csdnimg.cn/blog_migrate/b2715c32b410f1e151fc5368bb811663.png

SetConsoleCursorInfo函数

刚刚在修改CursorInfo变量的时候,要设置到控制台中就是使用了该函数,否则无法实现前面的效果

该函数的作用就是将我们刚刚创建的CONSOLE_CURSOR_INFO类型的变量CursorInfo里我们需要设置的值设置到我们的控制台中

https://i-blog.csdnimg.cn/blog_migrate/f6da581f8977bfb284946f371ddd8ad7.png

这里需要传两个参数,第一个还是我们前面说到的句柄,第二个则就是指针类型的 CONSOLE_CURSOR_INFO,也就是PCONSOLE_CURSOR_INFO

所以我们也是跟上一个函数一样传CONSOLE_CURSOR_INFO类型的变量CursorInfo的地址即可

SetConsoleCursorInfo(houtput, &CursorInfo);

这样我们的一系列有关控制台的修改的操作就完成了,下面展示测试代码

测试代码

void test1()
{
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	CONSOLE_CURSOR_INFO CursorInfo;
	GetConsoleCursorInfo(houtput, &CursorInfo);
	CursorInfo.bVisible = false;
	SetConsoleCursorInfo(houtput, &CursorInfo);
}

设置光标位置

我们先来看一个自定义类型COORD

COORD自定义类型

https://i-blog.csdnimg.cn/blog_migrate/1d97a58b9adef93bae1671898c769655.png

该类型内部封装了两个成员,一个x,一个y,代表坐标

我们可以将我们的控制台理解为一个平面坐标图

横向正方向是x轴正方向,竖向负方向是y轴正方向

https://i-blog.csdnimg.cn/blog_migrate/9931fa64f72aa84dbe0805ce412bca15.png

SetConsoleCursorPosition函数

https://i-blog.csdnimg.cn/blog_migrate/879fd7e5d06ae74bd4378dd022432105.png

该函数需要两个参数

第一个参数也是我们前面获取的句柄,第二个参数是刚刚讲到的COORD类型

所以我们只需要创建一个变量COORD,并给它的x和y赋值,就可以将我们的光标定位到我们想要的x和y的位置

我们的贪吃蛇游戏需要多次挪动我们的光标定位到指定位置,所以我们直接创建一个函数封装该功能

具体代码如下:

void SetPos(int x, int y)
{
	//获取句柄
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//获取位置放到pos变量中
	COORD pos = { x, y };
	//设置光标位置
	SetConsoleCursorPosition(houtput, pos);
}

这样我们有关控制台的修改就告一段落了,如果你能大概理解上面的部分也就踏出了一大步,在日常使用中我们也不需要记那么死,只需要知道大概用法和模板即可


我们要进行贪吃蛇游戏,肯定少不了我们使用键盘来控制,但我们按压键盘如何让我们的程序知道我们按压上这个按键了呢?

按键

GetAsyncKeyState函数

使用该函数需要一个头文件

#include <windows.h>

https://i-blog.csdnimg.cn/blog_migrate/5d9b4cfb7ae67bb52c05d9f0fb6f385a.png

该函数传的参数是一个按键的值

微软将每个按键都设定了一个值,所以我们只需要参照虚拟键码表即可填入参数

虚拟键码表:

那么我们如何使用这个函数呢?

主要要看这个函数的返回值

https://i-blog.csdnimg.cn/blog_migrate/a3021318152fc6fb56c874ffb254694f.png

GetAsyncKeyState函数的返回值是short类型

如果返回的16位short二进制数据中,最高位是1,说明按键的状态是按下,如果最高位是0,说明按键的状态是抬起。最低位被置为1,则说明该键被按过,没被按过则为0

结合我们贪吃蛇所需要的东西,其实我们只需要知道我们最低位是0还是1,就能知道该按键是否有被按过了

但是该函数并不能支持让我们单独完成这个任务,并且我们后面需要多次检测按键是否被按过,所以我们可以写一个宏,每次都可以检测一下该按键是否被按过

下面直接来展示代码

#define KEY_PRESS(KEY) ((GetAsyncKeyState(KEY) & 1) ? 1 : 0)

上面涉及到了宏和按位与&操作符,如果对上面操作有什么不理解的可以看看我前面的博客,或者看看下面的讲解能否看得懂

按位与&:

宏:

因为1用二进制表示只有最后一位是1,其他都是0 (00000001)

所以我们只需要将函数的返回值按位与&上1 (有0则为0)(使函数返回值除了最后一位其他位都变成0)

就可以得出两种结果,为1(按过)或者为0(没按过)

这里使用了三目运算符

所以如果按过该键,KEY_PRESS会返回1,否则返回0

这样我们就写好了一个宏,下次需要判断一个键是否被按过直接使用该宏并传该按键的虚拟键码即可

测试代码

void test2()
{
	while (1)
	{
		if (KEY_PRESS(0x30))
		{
			printf("0\n");
		}
		else if (KEY_PRESS(0x31))
		{
			printf("1\n");
		}
		else if (KEY_PRESS(0x32))
		{
			printf("2\n");
		}
		else if (KEY_PRESS(0x33))
		{
			printf("3\n");
		}
		else if (KEY_PRESS(0x34))
		{
			printf("4\n");
		}
		else if (KEY_PRESS(0x35))
		{
			printf("5\n");
		}
		else if (KEY_PRESS(0x36))
		{
			printf("6\n");
		}
		else if (KEY_PRESS(0x37))
		{
			printf("7\n");
		}
		else if (KEY_PRESS(0x38))
		{
			printf("8\n");
		}
		else if (KEY_PRESS(0x39))
		{
			printf("9\n");
		}
	}
}

该测试代码是一个无限循环,我们在这个无限循环期间按压123456789任意一个数字按键就可以打印出该数字的值

该测试代码需要按压的键盘并非小数字键区的数字,而是键盘字母上面一行的数字

<locale.h>本地化

在贪吃蛇游戏中我们需要打印一些特殊字符,比如墙体□和蛇身●,还有我们的汉字都算特殊字符,也叫做宽字符

我们通过修改地区,程序可以改变它的行为来适应世界的不同区域,所以我们需要本地化

但我们并不是全部都希望修改为本地化的,所以C语言可以针对不同的类型进行修改

如果想要详细的理解可以看看下面的链接:

setlocale函数

https://i-blog.csdnimg.cn/blog_migrate/6b029f14a520a114d6424e3fc100ce07.png

该函数用于修改当前地区

第一个参数可以是我们要修改地区的某个类型,如果我们全部都需要修改那么就只需要传LC_ALL

在我们的代码中只需要传LC_ALL即可

第二个参数C标准中仅有两种取值:

1.“C”(正常模式) 2.""(本地模式)

如果我们需要本地化的话我们当然就是要本地模式了,所以代码如下:

setlocale(LC_ALL, "");

宽字符的打印

我们打印宽字符可以使用wprintf函数

具体使用方法和printf函数基本一致

wprintf(L"%lc", L'●');

我们需要在参数前面加个L,然后再输入占位符,里面的%lc可以理解为printf里的%c

测试代码
void test3()
{
	wprintf(L"%lc\n", L'●');
	printf("%c", 'a');
}

这里输入了一个宽字符和一个普通字符做对比

https://i-blog.csdnimg.cn/blog_migrate/23c7f45b15647de42f45f9a53cd14f28.png

这里可以很明显的看出来,宽字符明显比普通字符宽,但是长度缺和它一样

所以可以得出结论:

宽字符需要占两个两个字节

https://i-blog.csdnimg.cn/blog_migrate/c30c06d3461e6efa043db1891130aaed.png

到这我们需要的新知识就结束了,恭喜你迈出了一大步,接下来就开始我们贪吃蛇的游戏设计和分析

贪吃蛇的游戏设计和分析

贪吃蛇数据结构

typedef struct SnakeNode
{
	//坐标
	int x;
	int y;
	//指向下一节点的指针
	struct SnakeNode* next;
}SnakeNode, *pSnakeNode;

typedef struct Snake
{
	pSnakeNode psnake; //指向蛇头的指针
	pSnakeNode pfood; //指向食物的指针
	enum DIRECTION dir; //蛇的方向
	enum GAME_STATE state; //游戏的状态
	int food_weight; //单个食物的分数
	int score; //总分数
	int sleep_time; //休息时间(蛇的速度)
}Snake, *pSnake;

SnakeNode类型为后面链表做准备

Snake是我们后续需要用到的属性

typedef后面有两个名字

例如Snake的typedef后面有一个Snake和一个*pSnake

Snake则代表struct Snake,pSnake代表struct Snake

这里还需要两个枚举类型

enum DIRECTION
{
	UP = 1,
	DOWN,
	LEFT,
	RIGHT
};

enum GAME_STATE
{
	OK,
	KILL_BY_WALL,
	KILL_BY_SELF,
	END_NORMAL	//正常退出
};

一个表示方向,一个表示游戏的状态

目前还有点懵的话是正常的,结合后面的代码来看会好得多

游戏准备工作

先来看看完整函数

void GameStart(pSnake ps)
{
	//设置窗口大小
	system("mode con cols=100 lines=30");
	//设置窗口标题
	system("title 贪吃蛇");
	//获得houtput句柄(输出设备)
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//定义CursorInfo变量用于存放控制台数据或改变控制台数据
	CONSOLE_CURSOR_INFO CursorInfo;
	//配合句柄使用函数获取命令台光标大小和清晰度放入CursorInfo变量中
	GetConsoleCursorInfo(houtput, &CursorInfo);
	//改变光标清晰度为0(隐藏光标)
	CursorInfo.bVisible = false;
	//设置到houtput句柄中改变控制台的光标
	SetConsoleCursorInfo(houtput, &CursorInfo);

	//打印欢迎界面和功能介绍
	WelcomeToGame();
	//绘制地图
	CreatMap();
	//创建蛇
	CreatSnake(ps);
	//创建食物
	CreatFood(ps);
}

接下来一步步的完成它

打印欢迎界面

void WelcomeToGame()
{
	SetPos(40, 13);
	printf("欢迎来到贪吃蛇小游戏\n");
	SetPos(40, 16);
	system("pause");
	system("cls");
	SetPos(30, 12);
	wprintf(L"用↑. ↓. ←. →控制蛇的移动,shift加速,ctrl减速\n");
	SetPos(30, 13);
	wprintf(L"加速能获得更高得分数\n");
	SetPos(30, 16);
	system("pause");
	system("cls");
}

这个步骤非常简单,我们只需要用前面讲的知识和函数即可

先用SetPos定位,然后再打印到我们的控制台中即可

这个数值全凭自己的审美设定即可

创建地图

void CreatMap()
{
	//打印最上面的边界
	int i = 0;
	for (i = 0; i < 29; i++)
	{
		wprintf(L"%lc", WALL);
		//或wprintf(L"%lc", L'□'); %lc是wprintf中字符的占位符,‘,’前后都要加L
	}
	//打印最下面的边界
	SetPos(0, 26);
	for (i = 0; i < 29; i++)
	{
		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);
	}
}

这里主要是把墙给建了起来

就是用了四个for循环将墙体给搭建了起来

这里使用了宏WALL,宏的代码:

#define POS_X 22
#define POS_Y 4
#define BODY L'●'
#define FOOD L'★'
#define WALL L'□'

这里创建的是一个x为56(宽字符2个字节,所以是28个墙体),y为27的墙

创建蛇

到这里开始就要使用链表的知识了

void CreatSnake(pSnake ps)
{
	//创建蛇身五个节点
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		pSnakeNode newnode = (pSnakeNode)malloc(sizeof(SnakeNode));
		if (newnode == NULL)
		{
			perror("CreatSnake fail\n");
			return;
		}
		//初始化每个节点
		newnode->next = NULL;
		newnode->x = POS_X + 2 * i;
		newnode->y = POS_Y;
		if (ps->psnake == NULL)
		{
			ps->psnake = newnode;
		}
		else
		{
			newnode->next = ps->psnake;
			ps->psnake = newnode;
		}
	}

	//遍历链表,并打印xy坐标到屏幕上
	pSnakeNode cur = ps->psnake;
	while (cur)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}

	//初始化蛇
	ps->dir = RIGHT;
	ps->food_weight = 10;
	ps->score = 0;
	ps->sleep_time = 200;
	ps->state = OK;
}

这里创建了一个5个节点的链表,表示蛇有5个●代表身体

如果对链表的知识有所不解可以看看我的上上一篇博客:

并且在创建链表的同时,给它们赋值为初始位置

这里的x只能是2的倍数,因为宽字符是2个字节我们需要同意x坐标为2的倍数,否则会出现错位的情况

创建链表的时候要注意分为两种情况,一种是链表为空,一种是链表不为空

当我们的链表创建完成后(蛇创建完成),我们需要遍历整个链表,并且取出它们的x和y值打印到我们的屏幕上

最后我们将Snake的成员附上初值即可

创建食物

void CreatFood(pSnake ps)
{
	int x = 0;
	int y = 0;
again:
	//创建食物坐标并检验坐标的可行性
	do
	{
		x = rand() % 53 + 2;
		y = rand() % 25 + 1;
	} while (x % 2 != 0); //检验坐标是否为偶数
	pSnakeNode cur = ps->psnake;
	//检验是否与蛇身重合
	while (cur)
	{
		if (cur->x == x && cur->y == y)
		{
			goto again;
		}
		cur = cur->next;
	}

	pSnakeNode pfood = (pSnakeNode)malloc(sizeof(SnakeNode));
	//pfood->next = NULL;
	pfood->x = x;
	pfood->y = y;
	SetPos(x, y);
	wprintf(L"%lc", FOOD);
	ps->pfood = pfood;
}

我们的食物是需要在我们贪吃蛇的可活动范围内生成的

这里定义了一个x和y变量,我们现在需要做的事情是先在我们贪吃蛇的活动范围内随机生成一个坐标并且创建一个新节点,最后将这个坐标赋值给我们创建的新节点

但是坐标生成后我们还需要判断一下它的可行性

1.食物在地图内

2.x坐标必须为偶数,y坐标则随意

3.x和y坐标不能与任何一个蛇身相等

坐标随机数

若我们的食物x的范围为(2,54)y的范围为(1,25)

则我们需要先用%运算符限定范围

例如x的最小值为2,那么我们取余的最小值肯定为0,只有加2才能让最小值为2

最大值为54,那么取余的最大值再加上2必须要等于54,所以我们需要取余53,取余后最大值为52,52加上2就是我们的54

y的范围也是如此

既然有了随机数,但是根据我们前面学到的知识,这个随机数是伪随机,并不是完全随机的,所以我们需要在开始游戏前设置一个随机数种子

对该知识不了解的可以看看下面的博客

srand((unsigned int)time(NULL));

接下来我们先用了一个do while循环先随机生成一个坐标,循环条件则就为判断偶数的条件

当第一个条件满足之后,我们就会跳出循环,那么接下来就要判断第二个条件

因为我们的蛇身是用链表连接而成的,所以我们要遍历链表需要先创建一个指针,让指针指向蛇头

接下来就是判断该指针指向的节点的x和y值是否与我们的食物相等,若都相等则goto到我们设置的again上重新开始设置一个新的x和y的随机数

当我们满足了以上的所有条件后,只需要创建一个新的食物节点x和y接收刚刚的x和y随机数即可

最后就是将光标移动到那个x和y位置上,打印食物的图案即可

游戏的前置准备工作就全部做完了

接下来完成游戏开始时的代码


游戏开始

先来看看完整函数代码

void GameBegin(pSnake ps)
{
	PrintCueInfo();

	do
	{
		SetPos(65, 12);
		printf("总分数:%d", ps->score);
		SetPos(65, 13);
		printf("当前食物的分数:%2d", ps->food_weight);

		//判断按过哪个按键
		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->state = END_NORMAL;
		else if (KEY_PRESS(VK_SPACE))
			Pause();
		else if (KEY_PRESS(VK_SHIFT))
		{
			if (ps->sleep_time > 80)
			{
				ps->sleep_time -= 30;
				ps->food_weight += 2;
			}
		}
		else if (KEY_PRESS(VK_CONTROL))
		{
			if (ps->sleep_time < 320)
			{
				ps->sleep_time += 30;
				ps->food_weight -= 2;
			}
		}
		//贪吃蛇的移动
		SnakeMove(ps);
		//程序暂停表示移动速度
		Sleep(ps->sleep_time);

	} while (ps->state == OK);
}

我们要先判断游戏进行时玩家按下了什么按键,并作出相应的响应,然后让蛇移动一次,并且让程序休息,在这个过程中我们需要循环实现,当游戏的状态一直是OK的时候就一直进行循环

这样做我们肉眼看起来就像是蛇在移动

接下来我们开始一步步详细的实现它

打印提示信息

在我们的游戏开始时我们也是需要打印提示信息的,让玩家在游戏中也能清楚自己该按什么按键

void PrintCueInfo()
{
	SetPos(65, 6);
	printf("不能撞墙,不能撞到自己");
	SetPos(65, 7);
	printf("用↑. ↓. ←. →控制蛇的移动");
	SetPos(65, 8);
	printf("shift加速,ctrl减速");
	SetPos(65, 9);
	printf("按空格暂停,按ESC退出游戏");
	SetPos(65, 15);
	printf("lyw");
}

这里就是简单的光标移动打印

判断按键信息

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->state = END_NORMAL;
else if (KEY_PRESS(VK_SPACE))
	Pause();
else if (KEY_PRESS(VK_SHIFT))
{
	if (ps->sleep_time > 80)
	{
		ps->sleep_time -= 30;
		ps->food_weight += 2;
	}
}
else if (KEY_PRESS(VK_CONTROL))
{
	if (ps->sleep_time < 320)
	{
		ps->sleep_time += 30;
		ps->food_weight -= 2;
	}
}

接下来我们需要判断我们按过什么按键,以此来确定接下来的行为

如果是方向键我们会将ps->dir设置为按下的方向键(前面有使用枚举类型定义过方向)

如果是ESC会将状态ps->state设置为正常退出(END_NORMAL)(前面有使用枚举类型定义过状态)

如果是空格则我们需要暂停我们的程序

void Pause()
{
	while (1)
	{
		Sleep(200);
		if (KEY_PRESS(VK_SPACE))
			break;
	}
}

这里用了一个无限循环让程序暂停,当再次按下空格键则跳出循环即可

如果是Shift或者ctrl,我们则需要将程序的休息时间调低或调高,当然对应的食物分数也要提升或降低

这里我们并不能一直加速或减速,加速就需要调整我们程序的休息时间,若程序的休息时间为0贪吃蛇就已经飞出去了,减速也不能一直减,否则吃一个食物就变成负分数了

蛇移动一次

void SnakeMove(pSnake ps)
{
	//创建蛇将要走的下一步的节点
	pSnakeNode newnode = (pSnakeNode)malloc(sizeof(SnakeNode));
	if (newnode == NULL)
	{
		perror("SnakeMove fail");
		return;
	}
	newnode->next = NULL;

	//将下一步的坐标赋值给新节点
	switch (ps->dir)
	{
	case UP:
		newnode->x = ps->psnake->x;
		newnode->y = ps->psnake->y - 1;
		break;
	case DOWN:
		newnode->x = ps->psnake->x;
		newnode->y = ps->psnake->y + 1;
		break;
	case LEFT:
		newnode->x = ps->psnake->x - 2;
		newnode->y = ps->psnake->y;
		break;
	case RIGHT:
		newnode->x = ps->psnake->x + 2;
		newnode->y = ps->psnake->y;
		break;
	}

	//检测下一个坐标是否为食物
	if (NextIsFood(ps, newnode))
	{
		EatFood(ps, newnode);
	}
	else
	{
		NoFood(ps, newnode);
	}
	//检测下一个坐标是否为墙
	NextIsWall(ps);
	//检测下一个坐标是否为蛇身
	NextIsBody(ps);
}

首先创建一个节点记录蛇下一次要走的位置,数据就需要根据蛇的方向来计算

当我们找到了蛇的下一步的坐标,我们需要判断3个点

1.下一个坐标是否为食物

2.下一个坐标是否为墙

3.下一个坐标是否为蛇身

这里用了一个函数来判断是否为食物

bool NextIsFood(pSnake ps, pSnakeNode pn)
{
	if (ps->pfood->x == pn->x && ps->pfood->y == pn->y)
		return true;
	else
		return false;
}
若为食物
void EatFood(pSnake ps, pSnakeNode pn)
{
    //头插法
	ps->pfood->next = ps->psnake;
	ps->psnake = ps->pfood;

	//释放无用节点
	free(pn);
	pn = NULL;

	//打印新的蛇节点到屏幕,吃到食物无需释放尾节点
	pSnakeNode cur = ps->psnake;
	while (cur)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}

	ps->score += ps->food_weight;

	CreatFood(ps);
}

那么我们的蛇身则需要变长(链表中添加一个节点),这个节点我们可以选择我们在这个函数传参进来的pn(函数外的newnode节点),也可以选择我们的食物节点pfood

在我们的代码中是选择了食物节点,那么我们为了避免内存泄漏不要忘记释放掉pn节点

首先使用了头插法将食物节点的next指针指向蛇头,并且让食物节点作为蛇头即可

不要忘了free(pn)

接下来用了一个循环将我们新的蛇头打印在屏幕上,这样就当作是蛇身变长了

吃掉了食物当然要让总分数增加

最后重新创建一个新的食物即可

若不为食物
void NoFood(pSnake ps, pSnakeNode pn)
{
    //头插法
	pn->next = ps->psnake;
	ps->psnake = pn;

	SetPos(pn->x, pn->y);
	wprintf(L"%lc", BODY);

	//找到倒数第二个节点(需要让倒数第二个节点的next指针置为NULL)
	pSnakeNode cur = ps->psnake;
	while (cur->next->next)
	{
		cur = cur->next;
	}

	//打印空格覆盖掉原有的蛇身
	SetPos(cur->next->x, cur->next->y);
	printf("  ");

	//释放尾节点
	free(cur->next);

	//要让目前最后一个节点的next置为NULL,否则程序会崩溃
	cur->next = NULL;
}

那么我们需要让蛇前进到新节点pn的坐标上,从我们的代码逻辑上来看其实就是将新节点连接作为蛇头,尾节点释放掉,最后打印在屏幕上即可

这里的前两行依旧和刚刚一样使用的是头插法,让pn节点作为蛇头

接下来打印我们的新节点的图案到我们的控制台指定的x,y坐标上

然后这里需要定义一个cur指针找到倒数第二个节点,以此来释放掉最后一个节点

为什么要找到倒数第二个节点?而不是找到最后一个节点直接释放?

因为我们需要让倒数第二个节点的next指针置为NULL,否则在后面我们需要再次遍历蛇身的时候这个next指针会指向一块未知的区域,导致程序崩溃

不仅仅在内存上需要释放掉最后一个节点,在控制台上我们也需要把最后一个节点的x和y坐标打印的一个蛇身覆盖掉,方法是打印两个空格覆盖掉(蛇身的符号是个宽字符所以需要两个空格)

接下来就是判断坐标是否为墙

是否为墙
void NextIsWall(pSnake ps)
{
	if (ps->psnake->x == 0 || ps->psnake->x == 56
		|| ps->psnake->y == 0 || ps->psnake->y == 26)
	{
		ps->state = KILL_BY_WALL;
	}
}

只需要判断x的坐标是否为0或者56,y是否为0或者26即可

若地图不同则相应的数据是不同的,需要自己丈量

若为墙则说明撞到了墙,状态改成KILL_BY_WALL即可

是否为身体
void NextIsBody(pSnake ps)
{
	//从蛇头的下一个节点开始找
	pSnakeNode cur = ps->psnake->next;
	while (cur)
	{
		if (cur->x == ps->psnake->x && cur->y == ps->psnake->y)
		{
			ps->state = KILL_BY_SELF;
			break;
		}
		cur = cur->next;
	}
}

这里就是常规的遍历链表,若其中一个节点的坐标和我们的身体坐标相同,那么说明撞到了蛇身,状态改成KILL_BY_SELF即可

到这里我们的蛇移动一次的函数就完成了

程序休息

接下来就是让程序休息sleep_time设置的时间即可

Sleep(ps->sleep_time);

这样才能让人肉眼看起来像是蛇的移动

到这里游戏基本上已经完成的差不多了

接下来就是游戏结束后我们需要做的善后

游戏善后

void GameEnd(pSnake ps)
{
	switch (ps->state)
	{
	case KILL_BY_SELF:
		SetPos(40, 15);
		printf("撞到了自己,游戏结束");
		break;
	case KILL_BY_WALL:
		SetPos(40, 15);
		printf("撞到了墙,游戏结束");
		break;
	case END_NORMAL:
		SetPos(40, 15);
		printf("游戏正常退出");
		break;
	}

	//释放创建的所有链表
	pSnakeNode cur = ps->psnake;
	while (cur)
	{
		pSnakeNode del = cur;
		cur = cur->next;
		free(del);
	}

	SetPos(0, 27);
}

善后我们需要做两件事

1.打印游戏退出的原因(ESC正常退出、撞墙、撞蛇身)

2.释放链表所有动态开辟的内存,防止内存泄漏

所以这里用了switch,case语句查看游戏的状态,并在屏幕上打印相应的信息

接下来定义了一个cur指针变量遍历整个链表,并且定义了一个del指针变量记录删除的节点,把del指向的内存空间释放掉即可

最后的一个SetPos只是为了美观,让游戏结束时的光标指向最底下的空白位置打印结束信息

到这里有关贪吃蛇游戏的函数就基本上全部结束了

接下来看看main函数的细节

main函数

void testSnake()
{
	char flag = 0;
	do
	{
		//游戏开始前清屏
		system("cls");

		Snake snake = { 0 };

		GameStart(&snake);
		GameBegin(&snake);
		GameEnd(&snake);
		SetPos(40, 16);
		printf("再来一次(Y/N):");
		flag = getchar();
		//清理缓冲区
		while (getchar() != '\n');
	} while (flag == 'Y' || flag == 'y');
}

int main()
{
	//本地化修改,使得支持中文字符,LC_ALL表示针对所有含有项修改
	setlocale(LC_ALL, "");
	//随机数种子
	srand((unsigned int)time(NULL));

	testSnake();
	return 0;
}

这里的do while循环主要是实现游戏的再来一次

当玩家游戏结束后就可以通过输入Y(yes)或者N(no)来决定是否继续游戏,而不是手动重新开始程序的运行

这里定义的flag变量主要是用来控制循环的结束条件

第一个getchar函数让用户的输入赋值给flag

第二个循环的getchar主要作用是清理缓冲区,保证下一次缓冲区里没有任何数据,这样才能让用户的下一次输入存到flag里


贪吃蛇游戏的完整代码

我们的贪吃蛇游戏就到此结束了

下面是完整代码

Snake.h

#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
#include <stdbool.h>
#include <locale.h>
#include <time.h>

#define POS_X 22
#define POS_Y 4
#define BODY L'●'
#define FOOD L'★'
#define WALL L'□'

//GetAsyncKeyState函数的作用是检查该键是否被按过
//若按过,则bit位最低位为1,否则为0
#define KEY_PRESS(KEY) ((GetAsyncKeyState(KEY) & 1) ? 1 : 0)

enum DIRECTION
{
	UP = 1,
	DOWN,
	LEFT,
	RIGHT
};

enum GAME_STATE
{
	OK,
	KILL_BY_WALL,
	KILL_BY_SELF,
	END_NORMAL
};

typedef struct SnakeNode
{
	//坐标
	int x;
	int y;
	//指向下一节点的指针
	struct SnakeNode* next;
}SnakeNode, * pSnakeNode;

typedef struct Snake
{
	pSnakeNode psnake; //指向蛇头的指针
	pSnakeNode pfood; //指向食物的指针
	enum DIRECTION dir; //蛇的方向
	enum GAME_STATE state; //游戏的状态
	int food_weight; //单个食物的分数
	int score; //总分数
	int sleep_time; //休息时间(蛇的速度)
}Snake, * pSnake;

//设置光标位置
void SetPos(int x, int y);

//游戏开始程序
void WelcomeToGame();

//创建地图
void CreatMap();

//游戏开始时的界面
void GameStart(pSnake ps);

//游戏开始
void GameBegin(pSnake ps);

//打印提示信息
void PrintCueInfo();

//贪吃蛇的移动
void SnakeMove(pSnake ps);

//下一个节点是否为食物
bool NextIsFood(pSnake ps, pSnakeNode pn);

//吃到食物
void EatFood(pSnake ps, pSnakeNode pn);

//没吃到食物
void NoFood(pSnake ps, pSnakeNode pn);

//下一个节点是否为墙
void NextIsWall(pSnake ps);

//下一个节点是否为蛇身
void NextIsBody(pSnake ps);

//游戏的善后工作
void GameEnd(pSnake ps);

Snake.c

#include "Snake.h"

void SetPos(int x, int y)
{
	//获取句柄
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//获取位置放到pos变量中
	COORD pos = { x, y };
	//设置光标位置
	SetConsoleCursorPosition(houtput, pos);
}

void WelcomeToGame()
{
	SetPos(45, 13);
	printf("欢迎来到贪吃蛇小游戏\n");
	SetPos(45, 16);
	system("pause");
	system("cls");
	SetPos(40, 12);
	wprintf(L"用↑. ↓. ←. →控制蛇的移动,shift加速,ctrl减速\n");
	SetPos(40, 13);
	wprintf(L"加速能获得更高得分数\n");
	SetPos(40, 16);
	system("pause");
	system("cls");
}

void CreatMap()
{
	//打印最上面的边界
	int i = 0;
	for (i = 0; i < 29; i++)
	{
		wprintf(L"%lc", WALL);
		//或wprintf(L"%lc", L'□'); %lc是wprintf中字符的占位符,‘,’前后都要加L
	}
	//打印最下面的边界
	SetPos(0, 26);
	for (i = 0; i < 29; i++)
	{
		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);
	}
}

void CreatSnake(pSnake ps)
{
	//创建蛇身五个节点
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		pSnakeNode newnode = (pSnakeNode)malloc(sizeof(SnakeNode));
		if (newnode == NULL)
		{
			perror("CreatSnake fail\n");
			return;
		}
		//初始化每个节点
		newnode->next = NULL;
		newnode->x = POS_X + 2 * i;
		newnode->y = POS_Y;
		if (ps->psnake == NULL)
		{
			ps->psnake = newnode;
		}
		else
		{
			newnode->next = ps->psnake;
			ps->psnake = newnode;
		}
	}

	//遍历链表,并打印xy坐标到屏幕上
	pSnakeNode cur = ps->psnake;
	while (cur)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}

	//初始化蛇
	ps->dir = RIGHT;
	ps->food_weight = 10;
	ps->score = 0;
	ps->sleep_time = 200;
	ps->state = OK;
}

void CreatFood(pSnake ps)
{
	int x = 0;
	int y = 0;
again:
	//创建食物坐标并检验坐标的可行性
	do
	{
		x = rand() % 55 + 1;
		y = rand() % 25 + 1;
	} while (x % 2 != 0); //检验坐标是否为偶数
	pSnakeNode cur = ps->psnake;
	//检验是否与蛇身重合
	while (cur)
	{
		if (cur->x == x && cur->y == y)
		{
			goto again;
		}
		cur = cur->next;
	}

	pSnakeNode pfood = (pSnakeNode)malloc(sizeof(SnakeNode));
	//pfood->next = NULL;
	pfood->x = x;
	pfood->y = y;
	SetPos(x, y);
	wprintf(L"%lc", FOOD);
	ps->pfood = pfood;
}

//1.设置控制台外观 2.打印提示玩家信息 3.绘制地图
//4.创建蛇 5.创建食物
void GameStart(pSnake ps)
{
	//设置窗口大小
	system("mode con cols=100 lines=30");
	//设置窗口标题
	system("title 贪吃蛇");
	//获得houtput句柄(输出设备)
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//定义CursorInfo变量用于存放控制台数据或改变控制台数据
	CONSOLE_CURSOR_INFO CursorInfo;
	//配合句柄使用函数获取命令台光标大小和清晰度放入CursorInfo变量中
	GetConsoleCursorInfo(houtput, &CursorInfo);
	//改变光标清晰度为0(隐藏光标)
	CursorInfo.bVisible = false;
	//设置到houtput句柄中改变控制台的光标
	SetConsoleCursorInfo(houtput, &CursorInfo);

	//打印欢迎界面和功能介绍
	WelcomeToGame();
	//绘制地图
	CreatMap();
	//创建蛇
	CreatSnake(ps);
	//创建食物
	CreatFood(ps);
}

void PrintCueInfo()
{
	SetPos(65, 6);
	printf("不能撞墙,不能撞到自己");
	SetPos(65, 7);
	printf("用↑. ↓. ←. →控制蛇的移动");
	SetPos(65, 8);
	printf("shift加速,ctrl减速");
	SetPos(65, 9);
	printf("按空格暂停,按ESC退出游戏");
	SetPos(65, 15);
	printf("lyw");
}

void Pause()
{
	while (1)
	{
		Sleep(200);
		if (KEY_PRESS(VK_SPACE))
			break;
	}
}

bool NextIsFood(pSnake ps, pSnakeNode pn)
{
	if (ps->pfood->x == pn->x && ps->pfood->y == pn->y)
		return true;
	else
		return false;
}

void EatFood(pSnake ps, pSnakeNode pn)
{
	//头插法
	ps->pfood->next = ps->psnake;
	ps->psnake = ps->pfood;

	//释放无用节点
	free(pn);
	pn = NULL;

	//打印新的蛇节点到屏幕,吃到食物无需释放尾节点
	pSnakeNode cur = ps->psnake;
	while (cur)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}

	ps->score += ps->food_weight;

	CreatFood(ps);
}

void NoFood(pSnake ps, pSnakeNode pn)
{
	pn->next = ps->psnake;
	ps->psnake = pn;

	SetPos(pn->x, pn->y);
	wprintf(L"%lc", BODY);

	//找到倒数第二个节点(需要让倒数第二个节点的next指针置为NULL)
	pSnakeNode cur = ps->psnake;
	while (cur->next->next)
	{
		cur = cur->next;
	}

	//打印空格覆盖掉原有的蛇身
	SetPos(cur->next->x, cur->next->y);
	printf("  ");

	//释放尾节点
	free(cur->next);

	//要让目前最后一个节点的next置为NULL,否则程序会崩溃
	cur->next = NULL;
}

void NextIsWall(pSnake ps)
{
	if (ps->psnake->x == 0 || ps->psnake->x == 56
		|| ps->psnake->y == 0 || ps->psnake->y == 26)
	{
		ps->state = KILL_BY_WALL;
	}
}

void NextIsBody(pSnake ps)
{
	//从蛇头的下一个节点开始找
	pSnakeNode cur = ps->psnake->next;
	while (cur)
	{
		if (cur->x == ps->psnake->x && cur->y == ps->psnake->y)
		{
			ps->state = KILL_BY_SELF;
			break;
		}
		cur = cur->next;
	}
}

void SnakeMove(pSnake ps)
{
	//创建蛇将要走的下一步的节点
	pSnakeNode newnode = (pSnakeNode)malloc(sizeof(SnakeNode));
	if (newnode == NULL)
	{
		perror("SnakeMove fail");
		return;
	}
	newnode->next = NULL;

	//将下一步的坐标赋值给新节点
	switch (ps->dir)
	{
	case UP:
		newnode->x = ps->psnake->x;
		newnode->y = ps->psnake->y - 1;
		break;
	case DOWN:
		newnode->x = ps->psnake->x;
		newnode->y = ps->psnake->y + 1;
		break;
	case LEFT:
		newnode->x = ps->psnake->x - 2;
		newnode->y = ps->psnake->y;
		break;
	case RIGHT:
		newnode->x = ps->psnake->x + 2;
		newnode->y = ps->psnake->y;
		break;
	}

	//检测下一个坐标是否为食物
	if (NextIsFood(ps, newnode))
	{
		EatFood(ps, newnode);
	}
	else
	{
		NoFood(ps, newnode);
	}
	//检测下一个坐标是否为墙
	NextIsWall(ps);
	//检测下一个坐标是否为蛇身
	NextIsBody(ps);
}

void GameBegin(pSnake ps)
{
	PrintCueInfo();

	do
	{
		SetPos(65, 12);
		printf("总分数:%d", ps->score);
		SetPos(65, 13);
		printf("当前食物的分数:%2d", ps->food_weight);

		//判断按过哪个按键
		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->state = END_NORMAL;
		else if (KEY_PRESS(VK_SPACE))
			Pause();
		else if (KEY_PRESS(VK_SHIFT))
		{
			if (ps->sleep_time > 80)
			{
				ps->sleep_time -= 30;
				ps->food_weight += 2;
			}
		}
		else if (KEY_PRESS(VK_CONTROL))
		{
			if (ps->sleep_time < 320)
			{
				ps->sleep_time += 30;
				ps->food_weight -= 2;
			}
		}
		//贪吃蛇的移动
		SnakeMove(ps);
		//程序暂停表示移动速度
		Sleep(ps->sleep_time);

	} while (ps->state == OK);
}

void GameEnd(pSnake ps)
{
	switch (ps->state)
	{
	case KILL_BY_SELF:
		SetPos(40, 15);
		printf("撞到了自己,游戏结束");
		break;
	case KILL_BY_WALL:
		SetPos(40, 15);
		printf("撞到了墙,游戏结束");
		break;
	case END_NORMAL:
		SetPos(40, 15);
		printf("游戏正常退出");
		break;
	}

	//释放创建的所有链表
	pSnakeNode cur = ps->psnake;
	while (cur)
	{
		pSnakeNode del = cur;
		cur = cur->next;
		free(del);
	}

	SetPos(0, 27);
}

test.c

#include "Snake.h"

void testSnake()
{
	char flag = 0;
	do
	{
		//游戏开始前清屏
		system("cls");

		Snake snake = { 0 };

		GameStart(&snake);
		GameBegin(&snake);
		GameEnd(&snake);
		SetPos(40, 16);
		printf("再来一次(Y/N):");
		flag = getchar();
		//清理缓冲区
		while (getchar() != '\n');
	} while (flag == 'Y' || flag == 'y');
}

int main()
{
	//本地化修改,使得支持中文字符,LC_ALL表示针对所有含有项修改
	setlocale(LC_ALL, "");
	//随机数种子
	srand((unsigned int)time(NULL));

	testSnake();
	return 0;
}