C语言贪吃蛇项目

前言——

相信学c语言的人都知道贪吃蛇这个项目。

对于初学者来说,这无疑是c语言初阶到c语言进阶的分水岭。
因为大多数贪吃蛇项目都要用到枚举与结构体,以及数据结构中的单链表,还有Windows库函数,涉及到的知识点比较很多。
但就像扫雷游戏一样,函数啊程序啊代码啊都是遵循逻辑的。再好的游戏再好的项目,也都是由一个个小的项目通过逻辑串联或并联成一个更大的项目的。

对于贪吃蛇游戏来说,实现其基本功能差不多要600行代码,只需一个头文件以及两个源文件即可实现,和扫雷游戏比只是多了一些代码而已。

来讲下贪吃蛇游戏的基本逻辑。

首先,需要对控制台(也就是运行代码后的那个窗口)窗口进行更改,这与以往不同的是我们需要实现定点刷新界面,也就是打印不再是一行一行打了,而是定位到某点开始打印,即定点刷新。要实现这个功能需要控制光标,此外我们也需要通过光标控制来打印基本菜单,不再需要循环打印菜单了。

之后需要打印游戏墙体,这里不再使用ASCII的特殊字符了,而是调用宽体字符打印墙体,还有贪吃蛇本体以及食物。

此外还需要创建蛇身链表,以及控制蛇身行动的函数。由于蛇的行动以及各项参数过多,又需要创建一个结构体存放这些参数。同样食物也同蛇身一样,需要随机分布在墙所围成的区域内,这又要调用time.h库函数,也要判定蛇是否吃掉食物,吃掉会怎样没吃掉又会怎么样...

蛇每次走一步也需要判定是否撞到自己或者撞到墙,还有每次游戏结束后,也要释放掉蛇身链表。

是不是觉得很复杂?没事的,大家都一样。

接下来和扫雷一样,分成三个部分——也就是一个头文件两个源文件——开始讲解:

1. snake.h    存放相关结构体、枚举、函数声明以及库函数

2. snake.c    存放游戏相关函数

3. text.c        存放主函数

注意!本文使用环境为VS2022,在VS2022上运行是没有问题的,如果发生报错冲突,还请自行依据自身环境更改出错代码,不过大多数应该都是大差不差的。

目录

前言——

1. snake.h

2. snake.c

2.1. SetPos 定位光标位置

2.2. GameStart 游戏初始化

2.2.1. welcomeToGame 欢迎界面打印

2.2.2. CreateMap 地图绘制

2.2.3. InitSnake 创建蛇

2.2.4. CreateFood 创建食物

2.3. GameRun 游戏运行基本逻辑

2.3.1. PrintHelpInfo 打印帮助信息

2.3.2. Pause 设置刷新时间

2.3.3. #define 判断按键宏

2.3.4. 判定按键

2.3.5.SnakeMove 蛇的移动

2.3.5.1. NextIsFood 判断下一步是不是食物

2.3.5.2. EatFood 下一个位置是食物

2.3.5.3. NoFood 下一个位置不是食物

2.3.5.3. KillByWall 检测蛇是否撞墙

2.3.5.4. KillBySelf 检测蛇是否撞上自己

2.3.6. 函数本体

2.4. GameEnd 游戏善后的工作

test.c

源码

snake.h

snake.c

text.c


1. snake.h

首先是include各种需要的头文件:

#include<stdio.h>
#include<Stdlib.h>
#include<windows.h>
#include<stdbool.h>
#include<time.h>

声明windows头文件是因为我们需要编辑窗口大小,以及做隐藏、定位光标等操作,该头文件里有我们需要的库函数,这些内容将在snake.c讲解。

声明完头文件,我们还需要定义下面几个宏:

#define POS_X 24
#define POS_Y 5

#define WALL L'□'
#define BODY L'●'
#define FOOD L'★'

POS_X以及POS_Y是游戏开始蛇的初始位置,这部分可以先跳过。

下面三行宏自上到下分别代表:墙体、蛇身以及食物。

前言说过,我们打印墙体需要使用到宽体字符,原因在于我们控制台窗口(对于正方形窗口来说)它的行数和列数是不一致的,其行间距 == 2列间距

如下图,上面的列数是大于行数的。

其原因在于c语言字符默认采用ASCII编码的,而ASCII字符集采用的是单字节形式,也就是占八个bit位。由于首位为0,其余七位才表示实际数据,所以ASCII编码只有128个字符,其余127个字符则是由地区决定的。也就是说,ASCII编码前128个字符大家都是一样的,不一样的为后127个字符

对于西方使用英语的国家来说,128个字符基本是够用的,但对于大部分东方国家是完全不够用的。对于我们汉字来说,文字体量巨大,就算是文学家也不敢说全认识汉字。所以255个字符是完全不够用的,为了解决这个问题,规定汉字使用两个字节来存储,其能存储的汉字就有2的16次方个,也就是65536个。

对于用两个字节存储的字符,微软将其称为 “ 宽体 ”字符

所以,上面宏定义中前缀 L" ",表示其为宽体字符

要想使用宽体字符,需要修改 “ 当前地区 ”,这部分将在test.c中讲到。

至于如何将宽体字符输入到编译器中,可以借助电脑自带的输入法。随便点一个键,然后点右边那个笑脸,三个选项中的最后一个就是特殊字符了:

将特殊字符输入到编译器后,运行的话一般都会弹出一段话:

点 “ 是 ” 就行。

Unicode就是宽体字符集的意思。

至于为什么控制台窗口会这样,可以简单理解为窄体字符打印是呈 “ 行宽列窄 ”的形式打印的,所以才会和上面红色填充的长方形范围一样只占一个 “ 正方形 ”的一半;而宽体字符就是 “ 正方形 ”的形式打印,就是上图中的绿色填充范围。

接下来是蛇相关的结构体以及枚举:

//蛇的身体
typedef struct SnakeNode 
{
	int x;//x坐标
	int y;//y坐标
	struct SnakeNode* next;//蛇身节点
}SnakeNode,* pSnakeNode;

首先来看第一个结构体。

前面说过,蛇身通过链表来实现,那么就需要定义一个结构体,且必须包含结构体指针。

对于蛇身来说,最重要的参数是它的位置,也就是坐标,那我们再在结构体里定义两个整型用以存放横纵坐标。

之后再将其重命名一下,这里要注意区分蛇身链表结构体以及下面的这个结构体:

//贪吃蛇基本数据
typedef struct Snake
{
	pSnakeNode _pSnake;//指向蛇头的指针
	pSnakeNode _pFood;//指向食物节点的指针
	enum DIRECTION _dir;//蛇的方向
	enum GAME_STATUS _status;//游戏的状态
	int _food_weight;//一个食物的分数
	int _score;//总成绩
	int _sleep_time;//休息时间,时间越短速度越快
}Snake,*pSnake;

前面说过,贪吃蛇游戏的参数比较多,需要定义一个结构体用以存放这些参数。

链表打印需要寻找它的头结点,也就是哨兵位,所以需要包含一个指向蛇头的指针。

同时,由于蛇是由链表构成的,相应的添加食物也需要链表来创建食物,所以也需要一个指向食物结点的指针,其食物结点的指针域置NULL。

游戏的进程通过成绩决定,而成绩分数则是由吃掉食物来进行增加操作。所以还需要两个整型变量用来存放总成绩和食物分数。

贪吃蛇游戏中蛇是可以加速的,所以需要一个整形变量用来存放刷新时间。刷新时间越短,蛇的速度越快;刷新时间越长,蛇的移动速度越慢。

当蛇上下移动时,需要向程序反馈这种 “ 方向性 ” 移动,而移动有上下左右四种方向,此时我们可以创建一个枚举类型,用来存放这些方向。因为蛇必须具有一个方向,不可能说这条蛇是 “ 无方向 ” 的,而枚举也恰好满足这个条件

//蛇的方向
enum DIRECTION
{
	UP = 1,
	DOWN,
	LEFT,
	RIGHT
};

由于枚举常量的值默认从0开始,所以我们可以把UP的值设为1。

那么这是上面结构体中第一个枚举类型。第二个枚举类型则是存储蛇的状态:

//蛇的状态
enum GAME_STATUS
{
	OK,//游戏正常
	KILL_BY_WALL,//撞到墙
	KILL_BY_SELF,//撞到自己
	END_NORMAL//正常退出
};

同上面逻辑一样,蛇的状态必然有一个取值,它要么是 “ 死 ” 的,要么是 “ 活 ” 的,总之不可能是又“死”又“活”的状态。

在后面的游戏逻辑中,我们需要借助这个枚举来判断游戏是否可以继续运作。

接下来就是函数的声明了,主要有三个部分:

1. 初始化部分

2. 游戏基本逻辑部分

3. 善后部分

具体将在 snake.c 中讲解。

//定位光标位置
void SetPos(short x, short y);
//游戏初始化
void GameStart(pSnake ps);
//欢迎界面打印
void welcomeToGame();
//地图绘制
void CreateMap();
//创建蛇
void InitSnake(pSnake ps);
//创建食物
void CreateFood(pSnake ps);

//游戏运行基本逻辑
void GameRun(pSnake ps);
//蛇的移动——走一步
void SnakeMove(pSnake ps);
//下一步是食物
int NextIsFood(pSnakeNode pn, pSnake ps);
//下一个位置是食物
void EatFood(pSnakeNode pn,pSnake ps);
//下一个位置不是食物
void NoFood(pSnakeNode pn, pSnake ps);
//检测蛇是否撞墙
void KillByWall(pSnake ps);
//检测蛇是否撞上自己
void KillBySelf(pSnake ps);

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

以下为 snake.h 头文件全部代码:

#define _CRT_SECURE_NO_WARNINGS 1
#pragma once

#include<stdio.h>
#include<Stdlib.h>
#include<windows.h>
#include<stdbool.h>
#include<time.h>

#define POS_X 24
#define POS_Y 5

#define WALL L'□'
#define BODY L'●'
#define FOOD L'★'

//蛇的方向
enum DIRECTION
{
	UP = 1,
	DOWN,
	LEFT,
	RIGHT
};

//蛇的状态
enum GAME_STATUS
{
	OK,//游戏正常
	KILL_BY_WALL,//撞到墙
	KILL_BY_SELF,//撞到自己
	END_NORMAL//正常退出
};

//蛇的身体
typedef struct SnakeNode 
{
	int x;//x坐标
	int y;//y坐标
	struct SnakeNode* next;//蛇身节点
}SnakeNode,* pSnakeNode;

//贪吃蛇基本数据
typedef struct Snake
{
	pSnakeNode _pSnake;//指向蛇头的指针
	pSnakeNode _pFood;//指向食物节点的指针
	enum DIRECTION _dir;//蛇的方向
	enum GAME_STATUS _status;//游戏的状态
	int _food_weight;//一个食物的分数
	int _score;//总成绩
	int _sleep_time;//休息时间,时间越短速度越快
}Snake,*pSnake;

//定位光标位置
void SetPos(short x, short y);
//游戏初始化
void GameStart(pSnake ps);
//欢迎界面打印
void welcomeToGame();
//地图绘制
void CreateMap();
//创建蛇
void InitSnake(pSnake ps);
//创建食物
void CreateFood(pSnake ps);

//游戏运行基本逻辑
void GameRun(pSnake ps);
//蛇的移动——走一步
void SnakeMove(pSnake ps);
//下一步是食物
int NextIsFood(pSnakeNode pn, pSnake ps);
//下一个位置是食物
void EatFood(pSnakeNode pn,pSnake ps);
//下一个位置不是食物
void NoFood(pSnakeNode pn, pSnake ps);
//检测蛇是否撞墙
void KillByWall(pSnake ps);
//检测蛇是否撞上自己
void KillBySelf(pSnake ps);

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

好的接下来是游戏最重要的部分——snake.c。

2. snake.c

接下来将分函数讲解,需格外注意蛇身链表与游戏数据两个结构体的区分,以及结构体内部变量的命名区分。

函数主要有: 

  1. SetPos                             定位光标位置
  2. GameStart                       游戏初始化
  3. welcomeToGame             欢迎界面打印
  4. CreateMap                       地图绘制
  5. InitSnake                          创建蛇
  6. CreateFood                      创建食物
  7. GameRun                         游戏运行基本逻辑
  8. SnakeMove                      蛇的移动——走一步
  9. NextIsFood                       下一步是食物
  10. EatFood                            吃食物
  11. NoFood                             下一个位置不是食物
  12. KillByWall                          检测蛇是否撞墙
  13. KillBySelf                           检测蛇是否撞上自己
  14. GameEnd                          游戏善后的工作

有些函数内部逻辑还需要额外创建几个小函数来支撑,就不在这里一一列出了。

2.1. SetPos 定位光标位置

//定位光标位置
void SetPos(short x, short y)
{
	//获取标准输出设备句柄
	HANDLE houtput = NULL;
	houtput = GetStdHandle(STD_OUTPUT_HANDLE);

	//定位光标
	COORD pos = { x,y };
	SetConsoleCursorPosition(houtput, pos);
}

该函数使用的库函数需要用 windows.h 声明后才能使用,此前讲过。

该头文件所包含的库函数在 windows 官方可以查到,而 GetStdHandle 函数:

该函数的返回值需要windows内置类型HANDLE接收,获取标准输出设备句柄。可以简单理解为获取设备相关信息的。

这个函数的返回值可以用来控制设备光标,也就是鼠标箭头,控制设备光标的函数就是下面的那个 SetConsoleCursorPosition 函数。

上面这也是windows官方给出的函数定义解释。

可以看到,该函数有两个参数,第一个就是上面那个 GetStdHandle 函数的返回值,另一个可以理解为将光标的信息——也就是横纵坐标——存放到 windows.h 的内置类型 COORD 中的一种值,所以代码才会这样写到:

COORD pos = { x,y };
SetConsoleCursorPosition(houtput, pos);

COORD 类型只需要放进两个参数即可,也就是光标的坐标。

在 snake.h 里我们讲过,控制台窗口它的横纵间距是不一致的,两者呈倍数关系。同时,在控制台窗口中,有平面坐标系如下:

左上角为坐标系原点,纵轴为y轴,而横轴为x轴。光标的坐标就是这样定位到的。

要注意它们之间的倍数关系,实际上是 2x==y

说是定位光标,其实是把光标移到给出的(x,y)坐标处。由于后续还会经常用到这个功能,所以他我们需要把它封装成一个函数。

而因为控制台窗口的长宽比较短,所以我们只需要传两个短整型 short 即可

2.2. GameStart 游戏初始化

//游戏初始化
void GameStart(pSnake ps)
{
	//设置窗口大小
	system("mode con cols=100 lines=30");
	system("title 贪吃蛇");
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//隐藏光标
	CONSOLE_CURSOR_INFO CursorInfo;
	GetConsoleCursorInfo(houtput, &CursorInfo);
	CursorInfo.bVisible = false;
	SetConsoleCursorInfo(houtput, &CursorInfo);

	//1.打印环境界面和功能介绍
	welcomeToGame();
	//2.绘制地图
	CreateMap();
	//3.创捷蛇
	InitSnake(ps);
	//4.创建食物
	CreateFood(ps);
}

在这里,我们先讲解前面几行代码,再分步讲解后面三个分支函数。

通过库函数system可以调整控制台窗口的设置,这里用到的是:

  1. mode con cols=100 lines=30 —— 调整窗口长为100,宽为30
  2. title 贪吃蛇 —— 更改控制台窗口名称为贪吃蛇

与上个函数的原理相同,先获取设备句柄,再将光标隐藏:

	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//隐藏光标
	CONSOLE_CURSOR_INFO CursorInfo;
	GetConsoleCursorInfo(houtput, &CursorInfo);
	CursorInfo.bVisible = false;
	SetConsoleCursorInfo(houtput, &CursorInfo);

CONSOLE_CURSOR_INFO 也是 windows.h 头文件的内置类型结构体,里面有个 bool 类型的参数可以修改光标的可见性,如果其为 ture,则光标可见;如果其为 false,则光标不可见

该内置类型结构体解释如下:

下面 GetConsoleCursorInfo 函数的第一个参数之前讲解过,是设备句柄。

第二个参数就是指向上面的 CONSOLE_CURSOR_INFO 类型结构体的指针。

该函数作用检索有关指定控制台屏幕缓冲区的游标大小和可见性的信息,之后可以对光标进行修改操作

SetConsoleCursorInfo 函数将修改后的光标设置运用到设备上,也就是说,将那个结构体内置 bool 类型的参数改为 “ false ” 后,需要通过这个函数将其运用到设备光标上

通过上述操作之后,我们就可以将光标隐藏了。此外,通过 CONSOLE_CURSOR_INFO 内置类型结构体也可以修改光标的大小,不过这里就不做说明了,接下来是分支函数的讲解。

2.2.1. welcomeToGame 欢迎界面打印

//欢迎界面打印
void welcomeToGame()
{
	//调用SetPos函数,定位光标位置
	SetPos(40, 14);
	wprintf(L"欢迎来到贪吃蛇小游戏\n");
	SetPos(42, 20);
	system("pause");
	system("cls");
	SetPos(25, 14);
	wprintf(L"用 ↑.↓.←.→ 来控制蛇的移动,按F3加速,F4减速\n");
	SetPos(25, 15);
	wprintf(L"加速能够得到更多的分数\n");

	SetPos(42, 20);
	system("pause");
	system("cls");

}

在 SetPos 函数讲解中提到,定位光标位置就是将光标移到给定坐标的位置。

在这个函数里,我们需要多次调用 SetPos 函数,调整帮助信息的位置,也就是分别调整光标位置,再打印帮助信息。

此外,我们还需要暂停功能,就是打印帮助信息后,要暂停一会供玩家看帮助信息,当玩家按下任意键后再继续打印后续内容。也就是说,需要一个界面按任意键切换到另一个界面的效果,介于每个帮助信息界面之间。

效果如下:

按任意键后,打印另一个帮助界面:

在C语言中,有个库函数恰好能满足我们的需求,也就是 system

要想使用这个函数得声明 stdlib.h 头文件。在 cplusplus.com 中,对于 system 函数解释如下:

总之,这个函数能实现很多窗口功能,据说可以实现电脑关机,不过我们需要使用的只有两种——冻结屏幕以及清屏。

当调用 system( " pause " )  时,就可以冻结屏幕,也即暂停,当按下任意键后取消冻结。

当调用 system( " cls " ) 时,就清除控制台窗口上打印的所有内容。

要想实现一个界面换到另一个界面效果,可以先打印初始界面再冻结屏幕,然后后按下任意键开始清除窗口全部内容,再打印新界面的内容就行了。

用代码实现这个功能就是:

	system("pause");
	system("cls");

我们希望将帮助信息打印到屏幕中间,这样可以美观一些,所以打印帮助信息需要借助 SetPos 函数,每次打印根据其打印信息长度不同都需要调整一下。

我们每次打印帮助信息后,再接着打上面这两行 system 即可

此外要注意,打印帮助信息不再是 printf 了,为了和墙体以及蛇身这些宽体字符一致,干脆将这些帮助信息也当成宽体字符打印就行了。

打印宽体字符需要用到 wprintf 打印,括号内部双引号前要跟L前缀,像这样:

wprintf(L"用 ↑.↓.←.→ 来控制蛇的移动,按F3加速,F4减速\n");

帮助信息看完后,接下来就是打印游戏墙体、蛇身以及食物。

2.2.2. CreateMap 地图绘制

//地图绘制
void CreateMap()
{
	int i = 0;
	//上
	for (i = 0; i < 29; i++)
	{
		wprintf(L"%lc", WALL);
	}

	//下
	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);
	}
}

因为 SetPos 函数可以修改光标的坐标了,由此,我们可以实现打印墙体上下左右的功能了。

这里要注意,本文贪吃蛇游戏地图墙体是 58 * 27 的,也就是横边占 58 个,纵边占 27

为什么横边不是 27 * 2 == 54 呢?因为这是墙体的!墙体左边和右边各占 2 个宽度,也就是四格,58 - 4 == 54 == 27 * 2 。同样道理,我们上边和下边也分别占了 1 格,由于纵边等于横边的 2 倍所以是一格。

也就是说,墙体是围绕游戏地图的大圈,其范围是:

上边——横坐标 x:0~56,纵坐标y:0。

下边——横坐标 x:0~56,纵坐标y:26。

左边——横坐标 x:0,纵坐标y:0~26。

右边——横坐标 x:56,纵坐标y:0~26。

宽体字符打印是根据当前坐标(x,y)打印到(x,y)到(x+2,y+2)的范围内,也就是下面这样,左上角视为打印起始坐标,打印范围覆盖到 2 * 2 (因为宽体字符占2个宽度间距)的正方形范围内。

因此,当 x == 56 时,打印范围是 56 ~ 58 ;而当 y == 26 时,因为纵间距==横间距2倍,所以覆盖范围是 26 ~ 27 。

光标在我们没有修改的情况下默认从(0,0)的位置开始,所以我们先打印上边的墙体:

	//上
    for (i = 0; i < 29; i++)
	{
		wprintf(L"%lc", WALL);
	}

打印宽字体字符需要用到 wprintf 且双引号前要加 L 前缀,由于是字符且已经在 snake.h 中宏定义了,我们只需要 %lc 打印 WALL 即可。

接下来通过定位光标打印墙体的下边:

	//下
	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);
	}

左右边打印就比较复杂一些,需要在循环内定位。

因为打印完后光标就跳到了紧跟着打印位置的下一个位置,所以我们需要重置光标位置,这时候就得借助循环定位光标

由于游戏地图的四个角我们先前打印上下边时已经打印了,所以可以省略到上下两边打印到的范围,循环打印直接从 1 开始,到 25 结束。

2.2.3. InitSnake 创建蛇

//创建蛇
void InitSnake(pSnake ps)
{
	int i = 0;
	pSnakeNode cur = NULL;

	for (i = 0; i < 5; i++)
	{
		cur = (pSnakeNode)malloc(sizeof(SnakeNode));
		if (cur == NULL)
		{
			perror("InitSnake()::malloc()");
			return;
		}
		cur->next = NULL;
		cur->x = POS_X + 2 * i;
		cur->y = POS_Y;

		//头插法插入链表
		if (ps->_pSnake == NULL)
		{
			//空链表情况下
			//ps->_psnake即为哨兵位(头节点)
			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->_score = 0;
	ps->_food_weight = 10;
	ps->_sleep_time = 200;
	ps->_status = OK;
}

从这里开始要注意蛇身结构体和游戏数据结构体的命名区别了。

此外还要传参传的是游戏数据结构体 ps。

蛇身结构体——SnakeNode:

//蛇的身体
typedef struct SnakeNode 
{
	int x;//x坐标
	int y;//y坐标
	struct SnakeNode* next;//蛇身节点
}SnakeNode,* pSnakeNode;

游戏基本数据——Snake:

//贪吃蛇基本数据
typedef struct Snake
{
	pSnakeNode _pSnake;//指向蛇头的指针
	pSnakeNode _pFood;//指向食物节点的指针
	enum DIRECTION _dir;//蛇的方向
	enum GAME_STATUS _status;//游戏的状态
	int _food_weight;//一个食物的分数
	int _score;//总成绩
	int _sleep_time;//休息时间,时间越短速度越快
}Snake,*pSnake;

到这里,我们先创建一个蛇身结构体,并将其置空:

pSnakeNode cur = NULL;

因为初始条件下,蛇身长度我们规定是五节,也就是五节单链表,所以我们 for 循环五次,将单链表穿起来。

要想使用链表需要 malloc 申请空间一下,每个结点都申请一次,如果申请失败,需要 perror 打印下错误信息并直接返回。

		cur = (pSnakeNode)malloc(sizeof(SnakeNode));
		if (cur == NULL)
		{
			perror("InitSnake()::malloc()");
			return;
		}

完成申请空间操作之后,我们将该结点的next指针置空,然后将其位置给确定一下:

		cur->next = NULL;
		cur->x = POS_X + 2 * i;
		cur->y = POS_Y;

POS_X 和 POS_Y 在 snake.h 中有提到过,是蛇的初始位置,且有:

POS_X == 24

POS_Y == 5

之后我们需要打印一条和上下边平行的蛇,也就是说蛇刚开始是和上下边平行的,所以我们保持y坐标不变,x 坐标每次递增 2 个间距即可保证蛇具有五节

当然,你也可以更改蛇的起始位置,不过本文以上述起始条件为主。

然后,我们采用头插法将结点串成链表:

		//头插法插入链表
		if (ps->_pSnake == NULL)
		{
			//空链表情况下
			//ps->_psnake即为哨兵位(头节点)
			ps->_pSnake = cur;
		}
		else
		{
			//非空链表情况下
			cur->next = ps->_pSnake;
			ps->_pSnake = cur;
		}

如果是第一个结点的话我们需要判断一下,若是,此前已经给 cur->next 置空了,所以我们只需要将游戏数据结构体 ps 中指向蛇头的指针设为当前结点即可。

若不是,则将 cur 头插进链表中。先将 cur->next 修改其指向指为ps中存储的蛇头指针 ps->_pSnake,然后再将这个新的头结点 cur 置入 ps->_pSnake。

注意!串联好后的蛇身链表每个坐标都是不一样的!

完成这步后,我们打印蛇身:

	//打印蛇身
	cur = ps->_pSnake;
	while (cur)
	{
		//定位蛇身节点,分别打印
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}

此前已经创建了临时结点 cur,我们可以节省空间继续利用它来遍历蛇身链表,对于 cur 来说上一个工作已经结束,现在给它 “ 加会班 ”。

利用 while 循环遍历链表,每遍历一次将光标定位到该结点存储的坐标(x,y),然后打印蛇身宽体字符(在snake.h中已设过宏),然后 cur = cur->next 即可

然后,我们修改一下游戏数据结构体ps中的数据:

	//设置贪吃蛇属性
	ps->_dir = RIGHT;
	ps->_score = 0;
	ps->_food_weight = 10;
	ps->_sleep_time = 200;
	ps->_status = OK;

修改内容为:

  1. _dir    蛇的方向——————改为:RIGHT朝右
  2. _score  总成绩——————改为:起始状态 0 分
  3. _weight 一个食物的分数——改为:10,在后面会对其进行操作
  4. _time 休息时间——————改为 200,将在后续讲解
  5. _status 游戏的状态————改为 OK,即蛇存活

2.2.4. CreateFood 创建食物

//创建食物
void CreateFood(pSnake ps)
{
	int x = 0;
	int y = 0;
	//生成的x必须为2的倍数
	//x:2~54
	//y:1~25
again:
	//循环主体若为奇数,则继续随机至偶数为止
	do
	{
		x = rand() % 53 + 2;
		y = rand() % 25 + 1;
	} while (x % 2 != 0);

	//x和y的坐标不能与蛇的身体冲突

	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("CreateFood()::malloc()");
		return;
	}

	pFood->x = x;
	pFood->y = y;
	pFood->next = NULL;

	//打印食物节点
	SetPos(x, y);
	wprintf(L"%lc", FOOD);

	ps->_pFood = pFood;
}

首先食物的坐标以及相应的分数是游戏的数据,所以我们要把 pSnake 类型的 ps 传过来。

初始化食物我们希望它是随机生成到某个点位的,所以需要利用库函数 rand 生成随机数,再将其控制在游戏地图的范围内:

again:
	//循环主体若为奇数,则继续随机至偶数为止	
    do
	{
		x = rand() % 53 + 2;
		y = rand() % 25 + 1;
	} while (x % 2 != 0);

由于游戏地图限制在 x 为 2~54 而 y 为 1~25 的范围内,我们通过取模来划定其范围,即赋给x的随机数 % 53,赋值给y的 % 25

因为当 x 随机数为 53 时,53 % 53 == 0,且当其为 52 时有 52 % 53 == 52,所以我们还需要取模后加上2,才能覆盖全我们想要的范围。

y的随机数取范围同理,可以自行推导一下。

这里要提醒一下,若要使用x的坐标时,其值必须为2的倍数,也即偶数

此前没有说到是因为我们都是打印,打印是从左到右打印的,光标跟随打印从左边移到右边,由于打印的是宽体字符所以不需要考虑x坐标问题。也是因为是宽体字符,打印时候光标的 x 坐标都是 2 的倍数。

为了与此一致,所以需要循环判断一下赋给x的随机数是不是2的倍数,若不是2的倍数则继续随机,直到赋值为 2 的倍数(也就是偶数)为止。

那个 again 的作用是判断这食物的随机坐标是不是蛇身结点的坐标,当两者冲突时就会发生打印错误,此时需要返回到 again 的位置重新运行。所以我们还要进行下面的判断:

	//x和y的坐标不能与蛇的身体冲突
    pSnakeNode cur = ps->_pSnake;
	while (cur)
	{
		if (x == cur->x && y == cur->y)
		{
			goto again;
		}
		cur = cur->next;
	}

判断过程很简单。先创建一个蛇身结构体临时变量 cur,将 ps 内存储的指向蛇头的指针 ps->_pSnake 赋给 cur 。然后 while 循环,判断是否其冲突。

若坐标重复——即位置冲突,我们可以直接 goto 到 again 的位置,再次获取随机坐标进行判断

当判断运行完成后,说明食物坐标与蛇身结点坐标不冲突,此时进入下一阶段:

	//创建食物节点
    pSnakeNode pFood = (pSnakeNode)malloc(sizeof(SnakeNode));
	if (pFood == NULL)
	{
		perror("CreateFood()::malloc()");
		return;
	}

	pFood->x = x;
	pFood->y = y;
	pFood->next = NULL;

	//打印食物节点
	SetPos(x, y);
	wprintf(L"%lc", FOOD);

	ps->_pFood = pFood;

再创建一个蛇身结点,用来存放获取到的食物坐标,需要 malloc 申请一下空间。

为什么是蛇身结点呢?为了方便。当蛇吃掉这个食物时,我们可以将其头插到蛇身链表里,这样无需做而外的处理了,而且也不需要而外创建啥食物结构体,岂不美哉?

malloc 结束后判断一下是否为空,然后将随机值赋给食物结点 pFood,将 pFood 的 next 置空处理。

在这里我们就不需要再读取 pFood 内的坐标了,我们此前创建的x和y变量还没消失,所以我们直接SetPos定位到这里,打印食物的宽体字符即可。和墙体字符一样,已经在头文件宏定义过了,注意是宽体字符打印。

最后将 pFood 放到 ps->_pFood 就可以了。

好了,游戏初始化函数我们已经写完了,文章至此已过半,接下来是游戏运行的基本逻辑。

2.3. GameRun 游戏运行基本逻辑

//游戏运行基本逻辑
void GameRun(pSnake ps)
{
	PrintHelpInfo();
	do
	{
		SetPos(64, 10);
		printf("总分数:%d\n", ps->_score);
		SetPos(64, 11);
		printf("当前食物的分数:%2d\n", 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_SPACE))
		{
			Pause();
		}
		else if (KEY_PRESS(VK_ESCAPE))
		{
			//正常退出游戏
			ps->_status = END_NORMAL;
		}
		else if (KEY_PRESS(VK_F3))
		{
			if (ps->_sleep_time > 80)
			{
				ps->_sleep_time -= 30;
				ps->_food_weight += 2;
			}
		}
		else if (KEY_PRESS(VK_F4))
		{
			if (ps->_food_weight > 2)
			{
				ps->_sleep_time += 30;
				ps->_food_weight -= 2;
			}
		}

		SnakeMove(ps);

		Sleep(ps->_sleep_time);

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

看着多其实也没多少,总体分三部分:打印信息、判断方向、判断按键信息。

这回先讲分支函数,由于与初始化函数逻辑结构不同,这里得先理解分支函数的作用,才能读懂大函数的逻辑。

2.3.1. PrintHelpInfo 打印帮助信息

//打印帮助信息
void PrintHelpInfo()
{
	SetPos(64,14);
	wprintf(L"%ls",L"不能穿墙,不能咬到自己");
	SetPos(64,15);
	wprintf(L"%ls",L"用↑.↓.←.→ 来控制蛇的移动");
	SetPos(64,16);
	wprintf(L"%ls",L"按F3加速,F4减速");
	SetPos(64,17);
	wprintf(L"%ls",L"按ESC退出游戏,按空格暂停游戏");
	SetPos(64,18);
	wprintf(L"%ls",L"CSDN.南天的波江座 制作");
}

这个函数没有在 snake.h 里定义,不过没有关系,一样可以使用。

上文能理解的话这里也就 “ 不攻自破 ”了,这里是在游戏地图外打印帮助信息,也就是右侧那一大片空白区域。

到这里可以修改一下自己的代码逻辑运行下整段代码看看,一般没错的话是能走通的。

要试验一下的话记得 test.c 文件里创建一下游戏数据结构体 pSnake 传参调用一下上文的初始化函数,然后再调用一下这个函数看看。

总之,到这一步后打印出来大概是这样的:

有些人的那个控制台窗口设置可能会不一样,打印的时候会出错,如果是上面的样式就不用改了嗷,要是下面这样的话:

没事的不用着急,按下面操作修改一下即可。

运行后找到窗口上面的这个 ∨ 符号,点进去后点设置,然后有个默认终端程序:

改为windows控制台主机:

点右下角保存后,删掉窗口重新运行下代码:

就可以了。要是还有打印错误估计是前面代码的位置错误了,可以看源码调整一下。

右边最底下的xxx制作你可以改成自己的名字或者其他啥的。

2.3.2. Pause 设置刷新时间

//设置刷新时间
void Pause()
{
	while (1)
	{
		Sleep(200);
		if (KEY_PRESS(VK_SPACE))
		{
			break;
		}
	}
}

该函数也没有在 snake.h 内定义。

这个函数内,我们需要无限循环调用 Sleep 函数,以使游戏刷新时间在不加速或减速的情况下为恒定值,这个恒定值就是该函数的参数,其单位为毫秒

该函数作用是使程序休眠一段时间,时间长度就是传参的值(以毫秒为单位)。

上面传 200 就是休眠 0.2 秒。

if 判断的是一个宏,如果这个宏的返回值为真,那就 break

在计算机领域里,SPACE 就是空格键的意思。这个判断的意思是,检测是否有按下空格键,若是返回真,此时跳出循环,程序暂停;若返回假,则程序继续运行。

那这个宏是怎么定义的呢?这个宏内的参数又为什么是这样写的呢?

2.3.3. #define 判断按键宏

//定义空格SPACE按下计算宏
#define KEY_PRESS(vk)  ((GetAsyncKeyState(vk)&1)?1:0)

这里的vk表示的是虚拟键,也就是我们键盘上的按键。

右边括号内的函数 GetAsyncKeyState 作用是检测给定的vk虚拟键是否按下,若按下返回真,没按下返回假。最后最好 & 1,因为会存在低位忽略问题。

将该函数的返回值 & 1 后用三目操作符来判定返回1或是0即可。

这个宏很多游戏项目检测玩家按键都会用到,且这样使用会造成一些问题,不过对于贪吃蛇来说还是没问题的,详情可另外学习了,这里不再介绍。

总而言之,这个宏的功能是判断玩家是否按下给定的检测按键 vk ,若按下,则返回 1,没按下则返回 0 分别对应真或假

可我们该怎么检测我们想检测的按键呢?

在Windows官网内,给出了键盘上每个按键的 “ 虚拟键值 ”并汇聚成表,也就是虚拟键值表。其值调用可用它的常量名称,也可以用十六进制数来表达:

常量名称其实就是按键英文名加个前缀 “ VK_ ” 而已。

十六进制在接下来的讲解中不会用到,使用的都是按键常量名称。是的,在 2.3.本章节一开始给的源码里那些VK都是代表按键的意思。

接下来具体讲解一下如何判定按键。

2.3.4. 判定按键

		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_SPACE))
		{
			Pause();
		}
		else if (KEY_PRESS(VK_ESCAPE))
		{
			//正常退出游戏
			ps->_status = END_NORMAL;
		}
		else if (KEY_PRESS(VK_F3))
		{
			if (ps->_sleep_time > 80)
			{
				ps->_sleep_time -= 30;
				ps->_food_weight += 2;
			}
		}
		else if (KEY_PRESS(VK_F4))
		{
			if (ps->_food_weight > 2)
			{
				ps->_sleep_time += 30;
				ps->_food_weight -= 2;
			}
		}

其实就是一长串连续判断来着哈哈哈哈。

到这里就要开始调用枚举了,防止各位忘记,在这里再给回忆一下:

//蛇的方向
enum DIRECTION
{
	UP = 1,
	DOWN,
	LEFT,
	RIGHT
};

每个if的意思是——如果玩家按下了这个方向按键且方向并不为相反方向。

UP 对应 DOWN,LEFT 对应 RIGHT,如果按键宏判定按下某个按键且在此之前枚举的是并不是相反方向的话,那就修改枚举的值为按下方向按键的方向

这么说吧,你见过谁家贪吃蛇能反着走的?自己能把自己吃了吗哈哈哈哈。

方向键以及后续要用到的键其对应的虚拟键常量名称是:

  1. VK_UP                  向上
  2. VK_DOWN           向下
  3. VK_LEFT              向左
  4. VK_RIGHT           向右
  5. VK_SPACE          空格(暂停)
  6. CK_ESCAPE       ESC(退出)
  7. VK_F3                  F3键
  8. VK_F4                  F4键

上下左右就是键盘右下角那四个箭头按键了。

对于前四个按键,也就是上下左右键,按下后判断蛇的方向是否无误,若真则修改枚举。

对于 空格 (也就是 SPACE )键,按下后调用前面讲过的刷新时间函数 Pause ,在其内部还有一次判定,按下这个键就暂停刷新屏幕了。

对于 ESC 键,为了与其他游戏一致,这个键代表退出。为真的话则修改游戏数据结构体ps中的游戏状态 ps->_status 为正常退出 END_NORMAL ,这也是个枚举,不过在后面才会做退出判定,这里简单介绍一下。

而 F3 键和 F4 键我们用来操作蛇的加减速功能。

		else if (KEY_PRESS(VK_F3))
		{
			if (ps->_sleep_time > 80)
			{
				ps->_sleep_time -= 30;
				ps->_food_weight += 2;
			}
		}
		else if (KEY_PRESS(VK_F4))
		{
			if (ps->_food_weight > 2)
			{
				ps->_sleep_time += 30;
				ps->_food_weight -= 2;
			}
		}

F3表示加速,F4表示减速

由于加减速各要有一个限制量,太快了人眼花,太慢了又要等很久,所以按下后还需要进行一次 if 判断,看看已经加速 / 减速到哪种程度了。

由于加减速修改了游戏的难度,相应地,吃掉一个食物的分数我们也要修改一下。

在这里规定加速每次可减30毫秒,最低减至80,也就是说,加速有四个档位。相应地我们食物的分数随着速度的变快,分数也有四次递增,每次递增2分

注意,修改分数需要调用 ps 修改

减速的话这里是用分数作限制,缩减到 2 分就不能再减速了。和加速一样,减速也有四个档位,减速的原理时使刷新时间增加。

减速减到底后需要加速才会恢复正常,加速反之。

至于上述改变如何应用,还请看下面这个函数:

2.3.5.SnakeMove 蛇的移动

//蛇的移动——走一步
void SnakeMove(pSnake ps)
{
	//创建一个结点,表示蛇即将到达的下一个节点
	pSnakeNode pNextNode = (pSnakeNode)malloc(sizeof(SnakeNode));
	if (pNextNode == NULL)
	{
		perror("SnakeMove()::malloc()");
		return;
	}

	//检测下一步路径
	switch (ps->_dir)
	{
	case UP:
		pNextNode->x = ps->_pSnake->x;
		pNextNode->y = ps->_pSnake->y - 1;
		break;
	case DOWN:
		pNextNode->x = ps->_pSnake->x;
		pNextNode->y = ps->_pSnake->y + 1;
		break;
	case LEFT:
		pNextNode->x = ps->_pSnake->x - 2;
		pNextNode->y = ps->_pSnake->y;
		break;
	case RIGHT:
		pNextNode->x = ps->_pSnake->x + 2;
		pNextNode->y = ps->_pSnake->y;
		break;
	}
	//检测下一个坐标是否是食物
	if (NextIsFood(pNextNode, ps))
	{
		EatFood(pNextNode, ps);
	}
	else
	{
		NoFood(pNextNode, ps);
	}
	//检测蛇是否撞墙
	KillByWall(ps);
	//检测蛇是否撞上自己
	KillBySelf(ps);
}

由于蛇身由链表组成,蛇的每次移动都可以看作是头插——尾删。也就是说,蛇走一步,就头插一个结点,尾删一个结点,以达到移动的目的。

这样,我们就得创建一个新结点,然后判断一下ps中存储的方向:

	//创建一个结点,表示蛇即将到达的下一个节点
	pSnakeNode pNextNode = (pSnakeNode)malloc(sizeof(SnakeNode));
	if (pNextNode == NULL)
	{
		perror("SnakeMove()::malloc()");
		return;
	}

	//检测下一步路径
	switch (ps->_dir)
	{
	case UP:
		pNextNode->x = ps->_pSnake->x;
		pNextNode->y = ps->_pSnake->y - 1;
		break;
	case DOWN:
		pNextNode->x = ps->_pSnake->x;
		pNextNode->y = ps->_pSnake->y + 1;
		break;
	case LEFT:
		pNextNode->x = ps->_pSnake->x - 2;
		pNextNode->y = ps->_pSnake->y;
		break;
	case RIGHT:
		pNextNode->x = ps->_pSnake->x + 2;
		pNextNode->y = ps->_pSnake->y;
		break;
	}

利用 switch 判断,上下的话 y 坐标分别减 1 加 1 ,同理左右分别是 x 坐标加 2 减 2 ,将计算后的结果赋给刚创建的新结点,最后插入操作由判断食物函数完成,这样就实现蛇移动的功能了。

完成这步后,开始判断下一步是不是食物。

2.3.5.1. NextIsFood 判断下一步是不是食物
	//检测下一个坐标是否是食物
	if (NextIsFood(pNextNode, ps))
	{
		EatFood(pNextNode, ps);
	}
	else
	{
		NoFood(pNextNode, ps);
	}

在这里,我们将用到三个判断是不是食物并作出对应操作的函数,分别是:

  1. NextIsFood   下一步是食物
  2. EatFood        吃掉食物
  3. NoFood         不是食物

接下来分别讲解,首先是判定执行条件 NextIsFood 函数:

//判断下一步是不是食物
int NextIsFood(pSnakeNode pn, pSnake ps)
{
	return (ps->_pFood->x == pn->x && ps->_pFood->y == pn->y);
}

很简单,传递食物结点的坐标和蛇下一步的坐标比较,若相等返回真,若不相等返回假,也就是是食物就返回真,不是食物就返回假

要注意传参的区别,两者是不一样的结构体。

2.3.5.2. EatFood 下一个位置是食物
//下一个位置是食物
void EatFood(pSnakeNode pn, pSnake ps)
{
	//头插法
	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;

	//重新创建食物
	CreateFood(ps);
}

若是,采用头插法,将食物结点头插进蛇身链表并释放结点

	//头插法
	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;

这步不再赘述,如果前文搞懂这步肯定不再话下。

最后分数加下就可以。

然后重新创建下食物,以便下一步操作:

	//重新创建食物
	CreateFood(ps);
2.3.5.3. NoFood 下一个位置不是食物
//下一个位置不是食物
void NoFood(pSnakeNode pn, pSnake ps)
{
	pn->next = ps->_pSnake;
	ps->_pSnake = pn;

	pSnakeNode cur = ps->_pSnake;
	while (cur->next->next != NULL)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}
	//把最后一个结点打印成空格
	SetPos(cur->next->x, cur->next->y);
	printf("  ");

	//释放最后一个结点
	free(cur->next);

	//将倒数第二个节点的地址置为NULL
	cur->next = NULL;
}

与上一个函数一样,采用头插法将结点插入蛇身链表,唯一不同的是,这里插入的是在SnakeMove里申请的新结点,而不是食物结点

头插完后,再打印一遍蛇身。实际上,除了游戏初始化打印蛇身,其他地方都是依靠是食物和不是食物这两个函数打印蛇身的

	pSnakeNode cur = ps->_pSnake;
	while (cur->next->next != NULL)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}
	//把最后一个结点打印成空格
	SetPos(cur->next->x, cur->next->y);
	printf("  ");

	//释放最后一个结点
	free(cur->next);

	//将倒数第二个节点的地址置为NULL
	cur->next = NULL;

创建一个临时结点,将 ps 内存储的蛇头结点赋给 cur。

由于蛇身最后一个结点我们要删除掉,所以循环打印时,只需循环到倒数第二个结点即可

然后定位光标到最后一个结点的位置,打印三个空格填补空缺,然后 free 释放掉最后一个结点,并将倒数第二个结点的 next 置空

到此,蛇的下一步动作我们都处理完成,接下来要判断蛇的下一步动作是否违规,即是否撞墙或撞到自己:

	//检测蛇是否撞墙
	KillByWall(ps);
	//检测蛇是否撞上自己
	KillBySelf(ps);
2.3.5.3. KillByWall 检测蛇是否撞墙
//检测蛇是否撞墙
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;
	}
}

和判断是不是食物一样简单,只需要四个 || ,将墙体坐标判断一下是否与下一步坐标相等

注意,运行此函数前,蛇已经通过判断食物这一程序移动到下一步,此刻 ps 内存储的蛇身头结点坐标即是蛇移动的下一步坐标,所以要判断的是这个。

如果撞墙,要将 ps 内的游戏状态改为撞到墙,这是之前头文件中定义过的枚举:

//游戏状态
enum GAME_STATUS
{
	OK,//游戏正常
	KILL_BY_WALL,//撞到墙
	KILL_BY_SELF,//撞到自己
	END_NORMAL//正常退出
};
2.3.5.4. KillBySelf 检测蛇是否撞上自己
//检测蛇是否撞上自己
void KillBySelf(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;
			break;
		}
		cur = cur->next;
	}
}

创建一个临时结点 cur,存放蛇的头结点的下一个结点,因为头结点是用来判断头结点坐标是否与身体坐标重复的,重复的话说明撞到自己了

循环遍历整个蛇身,如果重复说明撞到自己,要将 ps 内存储的游戏状态改为撞到自己,对应的枚举可见上一个函数讲解末尾。

到这里,蛇的移动逻辑也就理清了,游戏项目也近尾声,再坚持一下下哈哈哈哈。

2.3.6. 函数本体

讲回函数本体。

在进行上面一系列判断后,我们需要调用一下 Sleep ,将整个程序降下速:

		Sleep(ps->_sleep_time);

如果加速或减速了的话,其ps内部存储的时间已经改变,所以不需要再做其他的判定。

回看本章节函数开头代码,要注意是 do—while 循环,循环条件是游戏状态为 OK 的情况下:

while (ps->_status==OK);

如果不是 OK ,跳出循环并跳出 GameRun 函数,接下来进入游戏善后阶段。

2.4. GameEnd 游戏善后的工作

//游戏善后的工作
void GameEnd(pSnake ps)
{
	SetPos(24, 12);
	switch (ps->_status)
	{
		case END_NORMAL:
			wprintf(L"您主动结束游戏\n");
			break;
		case KILL_BY_WALL:
			wprintf(L"您撞到墙上,游戏结束\n");
			break;
		case KILL_BY_SELF:
			wprintf(L"您撞到了自己,游戏结束\n");
			break;
	}
	//释放蛇身的链表
	
	pSnakeNode cur = ps->_pSnake;
	while (cur)
	{
		pSnakeNode del = cur;
		cur = cur->next;
		free(del);
	}
}

该函数一共分为两部部分。

第一部分,定位到游戏地图中间一点的位置,通过switch来判断一下到底是什么引起的游戏终止。

打印效果如下:

第二部分,创建一个临时结点cur用来遍历整个蛇身链表,再在循环内创建一个临时变量用来释放结点:

	//释放蛇身的链表	
	pSnakeNode cur = ps->_pSnake;
	while (cur)
	{
		pSnakeNode del = cur;
		cur = cur->next;
		free(del);
	}

那么,到这里,游戏的基本逻辑讲解就结束了,最后是第二个头文件的讲解,也就是主函数的讲解。

test.c

#define _CRT_SECURE_NO_WARNINGS 1

#include<locale.h>
#include"snake.h"

void test()
{
	char ch;
	do 
	{
		system("cls");
		Snake snake = { 0 };
		//游戏开始
		GameStart(&snake);
		//游戏运行
		GameRun(&snake);
		//游戏结束
		GameEnd(&snake);
		SetPos(20, 15);
		printf("再来一局吗?(Y?N):");
		ch = getchar();
		/*getchar();*/
		while (getchar() != '\n');

	} while (ch=='Y'||ch=='y');
	SetPos(0, 27);
}

int main()
{
	//设置本地环境
	setlocale(LC_ALL, "");
	srand((unsigned int)time(NULL));
	test();
	return 0;
}

主函数里,因为我们打印墙体和蛇身以及其他一些东西用的是宽体字符,所以我们需要在这里配置一下本地环境,同时,也设置一下时间函数:

	//设置本地环境
	setlocale(LC_ALL, "");
	srand((unsigned int)time(NULL));

在cplusplus.com里,对于setlocale函数解释如下:

该函数需要声明头文件 locale.h 。

第一个参数LC_ALL表示对所有类项进行更改。

第二个参数如果是 “ C ” ,表示是正常模式;如果是 “” 也就是什么都不加,那就是本地模式。

修改完成后,为了主函数简洁一些,我们创建一个函数封装游戏运行需要的程序:

void test()
{
	int ch = 0;
	do 
	{
		ch = 0;
		system("cls");
		Snake snake = { 0 };
		//游戏开始
		GameStart(&snake);
		//游戏运行
		GameRun(&snake);
		//游戏结束
		GameEnd(&snake);
		SetPos(20, 15);
		printf("再来一局吗?(Y?N):");
		ch = getchar();
		/*getchar();*/
		while (getchar() != '\n');

	} while (ch=='Y'||ch=='y');
	SetPos(0, 27);
}

利用 do—while 循环判断玩家是否需要继续游戏,注意这里使用的是整型变量来接收,使用 char会出 bug。啊实际上整型也会有 bug,只要多次重复游玩就会直接终结程序,可能是某些内部因素无法忽略造成的。

在 do—while 循环内创建游戏数据结构体,并取地址按顺序传给三大游戏运行函数,之后定位到游戏地图中间那里打印一下是否需要再来一局,然后getchar接收一下,while循环判断玩家是否多次输入。

最后定位到游戏墙体下边的下一行,这样操作是为了终止程序后系统退出的那段话不与游戏界面冲突,前期游戏地图大小也预埋了这个坑来着。

好了,贪吃蛇项目至此结束嘞!

和扫雷差不多,你也可以添加些新函数来实现新功能,比如给墙啊给蛇啊上颜色,还可以添加多种食物或道具啥的。

咱们下期见!

接下来粘贴下源码。

源码

snake.h

#define _CRT_SECURE_NO_WARNINGS 1
#pragma once

#include<stdio.h>
#include<Stdlib.h>
#include<windows.h>
#include<stdbool.h>
#include<time.h>

#define POS_X 24
#define POS_Y 5

#define WALL L'□'
#define BODY L'●'
#define FOOD L'★'

//蛇的方向
enum DIRECTION
{
	UP = 1,
	DOWN,
	LEFT,
	RIGHT
};

//游戏状态
enum GAME_STATUS
{
	OK,//游戏正常
	KILL_BY_WALL,//撞到墙
	KILL_BY_SELF,//撞到自己
	END_NORMAL//正常退出
};

//蛇的身体
typedef struct SnakeNode 
{
	int x;//x坐标
	int y;//y坐标
	struct SnakeNode* next;//蛇身节点
}SnakeNode,* pSnakeNode;

//贪吃蛇基本数据
typedef struct Snake
{
	pSnakeNode _pSnake;//指向蛇头的指针
	pSnakeNode _pFood;//指向食物节点的指针
	enum DIRECTION _dir;//蛇的方向
	enum GAME_STATUS _status;//游戏的状态
	int _food_weight;//一个食物的分数
	int _score;//总成绩
	int _sleep_time;//休息时间,时间越短速度越快
}Snake,*pSnake;

//定位光标位置
void SetPos(short x, short y);
//游戏初始化
void GameStart(pSnake ps);
//欢迎界面打印
void welcomeToGame();
//地图绘制
void CreateMap();
//创建蛇
void InitSnake(pSnake ps);
//创建食物
void CreateFood(pSnake ps);

//游戏运行基本逻辑
void GameRun(pSnake ps);

//蛇的移动——走一步
void SnakeMove(pSnake ps);
//下一步是食物
int NextIsFood(pSnakeNode pn, pSnake ps);
//下一个位置是食物
void EatFood(pSnakeNode pn,pSnake ps);
//下一个位置不是食物
void NoFood(pSnakeNode pn, pSnake ps);
//检测蛇是否撞墙
void KillByWall(pSnake ps);
//检测蛇是否撞上自己
void KillBySelf(pSnake ps);

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

snake.c

#define _CRT_SECURE_NO_WARNINGS 1

#include"snake.h"

//定位光标位置
void SetPos(short x, short y)
{
	//获取标准输出设备句柄
	HANDLE houtput = NULL;
	houtput = GetStdHandle(STD_OUTPUT_HANDLE);

	//定位光标
	COORD pos = { x,y };
	SetConsoleCursorPosition(houtput, pos);
}

//欢迎界面打印
void welcomeToGame()
{
	//调用SetPos函数,定位光标位置
	SetPos(40, 14);
	wprintf(L"欢迎来到贪吃蛇小游戏\n");
	SetPos(42, 20);
	system("pause");
	system("cls");
	SetPos(25, 14);
	wprintf(L"用 ↑.↓.←.→ 来控制蛇的移动,按F3加速,F4减速\n");
	SetPos(25, 15);
	wprintf(L"加速能够得到更多的分数\n");

	SetPos(42, 20);
	system("pause");
	system("cls");

}

//地图绘制
void CreateMap()
{
	int i = 0;
	//上
	for (i = 0; i < 29; i++)
	{
		wprintf(L"%lc", WALL);
	}

	//下
	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 InitSnake(pSnake ps)
{
	int i = 0;
	pSnakeNode cur = NULL;

	for (i = 0; i < 5; i++)
	{
		cur = (pSnakeNode)malloc(sizeof(SnakeNode));
		if (cur == NULL)
		{
			perror("InitSnake()::malloc()");
			return;
		}
		cur->next = NULL;
		cur->x = POS_X + 2 * i;
		cur->y = POS_Y;

		//头插法插入链表
		if (ps->_pSnake == NULL)
		{
			//空链表情况下
			//ps->_psnake即为哨兵位(头节点)
			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->_score = 0;
	ps->_food_weight = 10;
	ps->_sleep_time = 200;
	ps->_status = OK;
}

//创建食物
void CreateFood(pSnake ps)
{
	int x = 0;
	int y = 0;
	//生成的x必须为2的倍数
	//x:2~54
	//y:1~25
again:
	//循环主体若为奇数,则继续随机至偶数为止
	do
	{
		x = rand() % 53 + 2;
		y = rand() % 25 + 1;
	} while (x % 2 != 0);

	//x和y的坐标不能与蛇的身体冲突

	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("CreateFood()::malloc()");
		return;
	}

	pFood->x = x;
	pFood->y = y;
	pFood->next = NULL;

	//打印食物节点
	SetPos(x, y);
	wprintf(L"%lc", FOOD);

	ps->_pFood = pFood;
}

//游戏初始化
void GameStart(pSnake ps)
{
	//设置窗口大小
	system("mode con cols=100 lines=30");
	system("title 贪吃蛇");
	HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
	//隐藏光标
	CONSOLE_CURSOR_INFO CursorInfo;
	GetConsoleCursorInfo(houtput, &CursorInfo);
	CursorInfo.bVisible = false;
	SetConsoleCursorInfo(houtput, &CursorInfo);

	//1.打印环境界面和功能介绍
	welcomeToGame();
	//2.绘制地图
	CreateMap();
	//3.创捷蛇
	InitSnake(ps);
	//4.创建食物
	CreateFood(ps);
}

//打印帮助信息
void PrintHelpInfo()
{
	SetPos(64,14);
	wprintf(L"%ls",L"不能穿墙,不能咬到自己");
	SetPos(64,15);
	wprintf(L"%ls",L"用↑.↓.←.→ 来控制蛇的移动");
	SetPos(64,16);
	wprintf(L"%ls",L"按F3加速,F4减速");
	SetPos(64,17);
	wprintf(L"%ls",L"按ESC退出游戏,按空格暂停游戏");
	SetPos(64,18);
	wprintf(L"%ls",L"CSDN.南天的波江座 制作");
}

//定义空格SPACE按下计算宏
#define KEY_PRESS(vk)  ((GetAsyncKeyState(vk)&1)?1:0)

//设置刷新时间
void Pause()
{
	while (1)
	{
		Sleep(200);
		if (KEY_PRESS(VK_SPACE))
		{
			break;
		}
	}
}

//检测下一步是不是食物
int NextIsFood(pSnakeNode pn, pSnake ps)
{
	return (ps->_pFood->x == pn->x && ps->_pFood->y == pn->y);
}

//下一个位置是食物
void EatFood(pSnakeNode pn, pSnake ps)
{
	//头插法
	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;

	//重新创建食物
	CreateFood(ps);
}

//下一个位置不是食物
void NoFood(pSnakeNode pn, pSnake ps)
{
	pn->next = ps->_pSnake;
	ps->_pSnake = pn;

	pSnakeNode cur = ps->_pSnake;
	while (cur->next->next != NULL)
	{
		SetPos(cur->x, cur->y);
		wprintf(L"%lc", BODY);
		cur = cur->next;
	}
	//把最后一个结点打印成空格
	SetPos(cur->next->x, cur->next->y);
	printf("  ");

	//释放最后一个结点
	free(cur->next);

	//将倒数第二个节点的地址置为NULL
	cur->next = NULL;
}

//检测蛇是否撞墙
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;
	}
}

//检测蛇是否撞上自己
void KillBySelf(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;
			break;
		}
		cur = cur->next;
	}
}

//蛇的移动——走一步
void SnakeMove(pSnake ps)
{
	//创建一个结点,表示蛇即将到达的下一个节点
	pSnakeNode pNextNode = (pSnakeNode)malloc(sizeof(SnakeNode));
	if (pNextNode == NULL)
	{
		perror("SnakeMove()::malloc()");
		return;
	}

	//检测下一步路径
	switch (ps->_dir)
	{
	case UP:
		pNextNode->x = ps->_pSnake->x;
		pNextNode->y = ps->_pSnake->y - 1;
		break;
	case DOWN:
		pNextNode->x = ps->_pSnake->x;
		pNextNode->y = ps->_pSnake->y + 1;
		break;
	case LEFT:
		pNextNode->x = ps->_pSnake->x - 2;
		pNextNode->y = ps->_pSnake->y;
		break;
	case RIGHT:
		pNextNode->x = ps->_pSnake->x + 2;
		pNextNode->y = ps->_pSnake->y;
		break;
	}
	//检测下一个坐标是否是食物
	if (NextIsFood(pNextNode, ps))
	{
		EatFood(pNextNode, ps);
	}
	else
	{
		NoFood(pNextNode, ps);
	}
	//检测蛇是否撞墙
	KillByWall(ps);
	//检测蛇是否撞上自己
	KillBySelf(ps);
}

//游戏运行基本逻辑
void GameRun(pSnake ps)
{
	PrintHelpInfo();
	do
	{
		SetPos(64, 10);
		printf("总分数:%d\n", ps->_score);
		SetPos(64, 11);
		printf("当前食物的分数:%2d\n", 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_SPACE))
		{
			Pause();
		}
		else if (KEY_PRESS(VK_ESCAPE))
		{
			//正常退出游戏
			ps->_status = END_NORMAL;
		}
		else if (KEY_PRESS(VK_F3))
		{
			if (ps->_sleep_time > 80)
			{
				ps->_sleep_time -= 30;
				ps->_food_weight += 2;
			}
		}
		else if (KEY_PRESS(VK_F4))
		{
			if (ps->_food_weight > 2)
			{
				ps->_sleep_time += 30;
				ps->_food_weight -= 2;
			}
		}

		SnakeMove(ps);

		Sleep(ps->_sleep_time);

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

//游戏善后的工作
void GameEnd(pSnake ps)
{
	SetPos(24, 12);
	switch (ps->_status)
	{
		case END_NORMAL:
			wprintf(L"您主动结束游戏\n");
			break;
		case KILL_BY_WALL:
			wprintf(L"您撞到墙上,游戏结束\n");
			break;
		case KILL_BY_SELF:
			wprintf(L"您撞到了自己,游戏结束\n");
			break;
	}

	//释放蛇身的链表	
	pSnakeNode cur = ps->_pSnake;
	while (cur)
	{
		pSnakeNode del = cur;
		cur = cur->next;
		free(del);
	}
}

text.c

#define _CRT_SECURE_NO_WARNINGS 1

#include<locale.h>
#include"snake.h"

void test()
{
	int ch = 0;
	do 
	{
		ch = 0;
		system("cls");
		Snake snake = { 0 };
		//游戏开始
		GameStart(&snake);
		//游戏运行
		GameRun(&snake);
		//游戏结束
		GameEnd(&snake);
		SetPos(20, 15);
		printf("再来一局吗?(Y?N):");
		ch = getchar();
		/*getchar();*/
		while (getchar() != '\n');

	} while (ch=='Y'||ch=='y');
	SetPos(0, 27);
}

int main()
{
	//设置本地环境
	setlocale(LC_ALL, "");
	srand((unsigned int)time(NULL));
	test();
	return 0;
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值