本篇主要内容是使用C语言在Windows环境的控制台中模拟实现经典小游戏贪吃蛇。
一、准备工作
我们要实现的基本功能有:
- 地图绘制
- 蛇吃食物的功能(上、下、左、右方向键控制蛇的动作)
- 蛇撞墙死亡
- 蛇撞自身死亡
- 计算得分
- 蛇身加速、减速
- 暂停游戏
我们需要知道的知识点有:C语言函数、枚举、结构体、动态内存管理、预处理指令、链表、Win32 API等。
在写游戏之前,首先我们要对一些知识了解一些。
1.1 Win32 API
Windows这个多作业系统除了协调应用程序的执行、分配内存、管理资源之外,它同时也是⼀个很大的服务中心,调用这个服务中心的各种服务(每一种服务就是一个函数),可以帮应用程序达到开启视窗、描绘图形、使用周边设备等目的,由于这些函数服务的对象是应用程序(Application),所以便称之为Application Programming Interface,简称API函数。Win32 API也就是Microsoft Windows 32位平台的应用程序编程接口。
1.2 控制台程序
平常我们运行起来的黑框程序其实就是控制台程序。
我们可以使用cmd命令来设置控制台窗口的长宽:设置控制台窗口的大小,30行,100列。
mode con cols=100 lines=30
也可以通过命令设置控制台窗口的名字:
title 贪吃蛇
这些能在控制台窗口执行的命令,也可以调用C语言函数system来执行。例如:
#include <stdio.h>
int main()
{
//设置控制台窗⼝的⻓宽:设置控制台窗⼝的⼤⼩,30⾏,100列
system("mode con cols=100 lines=30");
//设置cmd窗⼝名称
system("title 贪吃蛇");
return 0;
}
COORD
我们在控制台上实现贪吃蛇时,需要知道蛇的位置,墙体的位置,食物的位置等等,这些都离不开坐标,那么如何获得坐标呢?
COORD是WindowsAPI中定义的⼀个结构体,表示⼀个字符在控制台屏幕幕缓冲区上的坐标,坐标系 (0,0) 的原点位于缓冲区的顶部左侧单元格。
这和以往的我们印象可能不同,在左上角是坐标原点,从左往右为x轴,从上往下为y轴。
它的具体定义为:
其中,x为水平坐标或列值。 单位取决于函数调用。 y为垂直坐标或行值。 单位取决于函数调用。
例如,给坐标赋值:
COORD pos = { 10, 15 };
待会会用到,我们接着往下看。
GetStdHandle
GetStdHandle是一个WindowsAPI函数。它用于从一个特定的标准设备(标准输入、标准输出或标准错误)中取得⼀个句柄(用来标识不同设备的数值),使用这个句柄可以操作设备。大家可能对这个句柄有疑惑,大家不用被它的名字所吓到,我们可以理解为炒菜时我们手握的把柄,用来控制锅的移动,同样的,这里的句柄就是用来操纵一个标准设备的。
它的具体定义为:
其中参数:nStdHandle [in] 标准设备。 此参数的取值可为下列值之一。
因为我们游戏是在控制台中进行的,所以我们在传参时,传STD_OUTPUT_HANDLE即可。
它的返回值是一个指针。 在vs2019中定义如下:
我们可以这样使用:
#include <windows.h>
int main()
{
//获取标准输出设备的句柄
HANDLE houtput = NULL;
houtput = GetStdHandle(STD_OUTPUT_HANDLE);
}
GetConsoleCursorInfo
在游戏运行过程中,我们当然不想看到有光标在闪动,我们可以使用GetConsoleCursorInfo函数和SetConsoleCursorInfo来联合处理。
GetConsoleCursorInfo函数的功能是检索有关指定控制台屏幕缓冲区的光标大小和可见性的信息
它的具体定义为:
其中第1个参数我们已经了解到了,那么第2个参数是什么呢?
CONSOLE_CURSOR_INFO是一个结构体,而PCONSOLE_CURSOR_INFO是一个结构体指针,来指向这个结构体,这个结构体的成员:dwSize是由游标填充的字符单元的百分比。 该值介于 1 到 100 之间。 游标外观各不相同,范围从完全填充单元到显示为单元底部的横线。 bVisible是游标的可见性。 如果游标可见,则此成员为TRUE,否则为FALSE。
到这里相信大家已经明白了第二个函数的含义。
SetConsoleCursorInfo
我们已经了解了GetConsoleCursorInfo的大致意思了,那我们能不能定义一个结构体变量,类型为CONSOLE_CURSOR_INFO,来直接修改游标填充的字符单元的百分比和游标可见性的问题呢?
比如这样:
CONSOLE_CURSOR_INFO cursor_info={0};
cursor_info.dwSize=50;
cursor_info.bVisible=false;
这样可以吗?答案是:不可以。
那我们如何修改这些属性呢?这时候就需要用到SetConsoleCursorInfo这个函数了。
它的功能就是设置指定控制台屏幕缓冲区的光标的大小和可见性。
它的具体定义是:
它的参数同GetConsoleCursorInfo一样。
将这几个函数联合起来,就可以达到目的。
#include <stdio.h>
#include <stdbool.h>
#include <windows.h>
int main()
{
//获取标准输出设备的句柄
HANDLE houtput = NULL;
houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//定义一个光标信息的结构体
CONSOLE_CURSOR_INFO cursor_info = {0};
//获取和houtput句柄相关的控制台上的光标信息,存放在cursor_info中
GetConsoleCursorInfo(houtput, &cursor_info);
//修改光标的占比和可见性
cursor_info.dwSize = 25;
cursor_info.bVisible = false;
//设置和houtput句柄相关的控制台上的光标信息
SetConsoleCursorInfo(houtput, &cursor_info);
printf("%d\n",cursor_info.dwSize);
printf("%d\n",cursor_info.bVisible);
system("pause");
}
程序运行起来的界面是这样的:
光标就被隐藏起来了。
SetConsoleCursorPosition
我们要在指定位置打印指定信息,还需要任意时刻将光标定义到我们想要的位置上去。那么就需要SetConsoleCursorPosition这个函数来帮助我们达到目的。
它的具体定义为:
这两个参数我们在上边已经介绍过了,这里不过多赘述。至于返回值:如果该函数成功,则返回值为非零值。如果函数失败,则返回值为零。
它的功能是:设置指定控制台屏幕缓冲区中的光标位置。
我们将设置的坐标信息放在COORD类型的dwCursorPosition中,调用SetConsoleCursorPosition函数将光标位置设置到指定的位置。
我们就可以这样使用:
#include <stdio.h>
#include <stdbool.h>
#include <windows.h>
int main()
{
//获取标准输出设备的句柄
HANDLE houtput = NULL;
houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//定位光标位置
COORD pos = { 10,20 };
SetConsoleCursorPosition(houtput, pos);
getchar();//用于观察现象,无实际意义
}
运行程序:
这里光标位置就发生了变化。
我们可以根据此用法来定义一个函数,此函数的功能是只需传入一个坐标便可使光标定位到该坐标位置处。
//封装一个设置光标位置的函数
void SetPos(short x, short y)
{
//获取标准输出设备的句柄
HANDLE houtput = NULL;
houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//定位光标位置
COORD pos = { x,y };
SetConsoleCursorPosition(houtput, pos);
}
GetAsyncKeyState
在游戏运行过程中,我们要控制蛇的走向,必定需要按键,那么如何检测到按键信息呢?这时候GetAsyncKeyState函数就起到了作用。
它的定义如下:
它的功能使:获取按键情况。
将键盘上每个键的虚拟键值传递给函数,函数通过返回值来分辨按键的状态。
GetAsyncKeyState函数的返回值是short类型,在上一次调用GetAsyncKeyState函数后,如果返回的16位的short数据中,最高位是1,说明按键的状态是按下,如果最高位是0,说明按键的状态是抬起;如果最低位被置为1,则说明该按键被按过,若最低位被置为0,说明该键没有被按过。
如果我们要判断⼀个键是否被按过,可以检测GetAsyncKeyState返回值的最低值是否为1。
键盘上各个键的虚拟键码大家可以参考这里:虚拟键码
我们可以根据以上介绍来写一个宏,判断该键是否被按下
#define KEY_PRESS(VK) ( (GetAsyncKeyState(VK) & 0x1) ? 1 : 0 )
我们可以写一个程序来观察:
#define KEY_PRESS(VK) ( (GetAsyncKeyState(VK) & 0x1) ? 1 : 0 )
#include <stdio.h>
#include <windows.h>
int main()
{
while (1)
{
if (KEY_PRESS(0x30)) //传入数字0的虚拟键码,当按下0时会打印0
{
printf("0\n");
}
else if (KEY_PRESS(0x31)) //传入数字1的虚拟键码,当按下1时会打印1
{
printf("1\n");
}
else if (KEY_PRESS(0x32)) //传入数字2的虚拟键码,当按下2时会打印2
{
printf("2\n");
}
else if (KEY_PRESS(0x33)) //传入数字3的虚拟键码,当按下3时会打印3
{
printf("3\n");
}
}
return 0;
}
1.3 宽字符
在游戏地图上,我们打印墙体使用宽字符:□,打印蛇使用宽字符:●,打印食物使用宽字符:★,普通的字符是占一个字节的,这类宽字符是占用2个字节。
这里再简单的讲⼀下C语言的国际化特性相关的知识,过去C语言并不适合非英语国家(地区)使用。 C语言最初假定字符都是单字节的。但是这些假定并不是在世界的任何地方都适用。
C语言字符默认是采用ASCII编码的,ASCII字符集采用的是单字节编码,且只使用了单字节中的低7 位,最高位是没有使用的,可表示为0xxxxxxxx;可以看到,ASCII字符集共包含128个字符,在英语国家中,128个字符是基本够用的,但是,在其他国家语言中,比如,在法语中,字母上方有注音符号,它就无法用ASCII码表示。于是,⼀些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的é的编码为130(⼆进制10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多256个符号。但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕它们都使用256个符号的编码方式,代表的字母却不⼀样。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel,在俄语编码中又会代表另⼀个符号。但是不管怎样,所有这些编码方式中,0-127表示的符号是⼀样的,不⼀样的只是128-255的这⼀段。 至于亚洲国家的文字,使用的符号就更多了,汉字就多达10万左右。⼀个字节只能表示256种符号, 肯定是不够的,就必须使用多个字节表达⼀个符号。比如,简体中文常见的编码方式是GB2312,使用两个字节表示一个汉字,所以理论上最多可以表示256x256=65536个符号。
后来为了使C语言适应国际化,C语言的标准中不断加入了国际化的支持。比如:加入了宽字符的类型wchar_t 和宽字符的输入输出函数,加入了头文件,其中提供了允许程序员针对特定地区(通常是国家或者说某种特定语言的地理区域)调整程序行为的函数。
<locale.h>本地化
<locale.h>提供的函数用于控制C标准库中对于不同的地区会产生不⼀样行为的部分。在标准中,依赖地区的部分有以下几项:
- 数字量的格式
- 货币量的格式
- 字符集
- 日期和时间的表示形式
类项
通过修改地区,程序可以改变它的行为来适应世界的不同区域。但地区的改变可能会影响库的许多部分,其中⼀部分可能是我们不希望修改的。所以C语言支持针对不同的类项进行修改,下面的一个宏, 指定⼀个类项:
- LC_COLLATE:影响字符串比较函数strcoll()和strxfrm()。
- LC_CTYPE:影响字符处理函数的行为。
- LC_MONETARY:影响货币格式。
- LC_NUMERIC:影响printf()的数字格式。
- LC_TIME:影响时间格式strftime()和wcsftime()。
- LC_ALL:针对所有类项修改,将以上所有类别设置为给定的语言环境。
setlocale函数
setlocale函数用于修改当前地区,可以针对⼀个类项修改,也可以针对所有类项。
setlocale的第⼀个参数可以是前面说明的类项中的⼀个,那么每次只会影响⼀个类项,如果第⼀个参数是LC_ALL,就会影响所有的类项。
C标准给第⼆个参数仅定义了2种可能取值:"C"(正常模式)和""(本地模式)。
在任意程序执行开始,都会隐藏式执行调用:
setlocale(LC_ALL, "C");
当地区设置为"C"时,库函数按正常方式执行,小数点是一个点。 当程序运行起来后想改变地区,就只能显示调用setlocale函数。用""作为第2个参数,调用setlocale 函数就可以切换到本地模式,这种模式下程序会适应本地环境。 比如:切换到我们的本地模式后就支持宽字符(汉字)的输出等。
setlocale(LC_ALL, "");//切换到本地环境
宽字符的打印
如果我们想在屏幕上打印宽字符,该如何打印呢?
宽字符的字面量必须加上前缀“L”,否则C语言会把字面量当作窄字符类型处理。前缀“L”在单引号前面,表示宽字符,对应打印函数wprintf的占位符是%lc,如果打印的是宽字符串,对应的占位符是%ls。
具体演示代码如下:
#include <stdio.h>
#include <locale.h>
int main()
{
setlocale(LC_ALL, "");
char a = 'a';
char b = 'b';
printf("%c%c\n",a,b);
wchar_t ch1 = L'开';
wchar_t ch2 = L'心';
wprintf(L"%lc%lc\n",ch1,ch2);
printf("%d\n",sizeof(ch1));//2
return 0;
}
打印结果如下:
不难发现,一个宽汉字字符占两个字节。那么我们如果要在贪吃蛇中使用宽字符,就需要处理好地图上与坐标相关的问题。
例如:
以上就是实现贪吃蛇前的准备工作。接下来,我们共同来实现贪吃蛇小游戏。
二、贪吃蛇游戏的实现
首先,我们想一下,实现这个游戏需要什么(按顺序想)?
- 欢迎界面
- 游戏界面
- 墙
- 蛇
- 食物
- 分数
- 食物的权重
- 蛇的速度
我们可以创建3个文件,一个test.c源文件用于测试游戏逻辑是否正确,一个snake.h头文件用于函数的声明以及相关结构体的实现和库函数的包含,一个snake.c源文件用于实现头文件中声明的函数。
在这里,我们蛇用链表表示(即一个个节点串联起来),那么我们就需要用到结构体,在结构体中需要有每个节点的坐标以及指向下一个节点的指针。具体代码如下:
//蛇身节点
typedef struct SnakeNode
{
//坐标
int x;
int y;
//指向下一个节点的指针
struct SnakeNode* next;
}SnakeNode, * pSnakeNode;
在蛇的移动过程中,蛇头会变,吃掉食物时,食物会再次生成一个,即食物也会变,蛇的运动方向,游戏的状态,一个食物的分数,总成绩,蛇的速度我们都需要考虑进去。所以我们在来一个结构体来将它们组织起来。具体代码如下:
//蛇的运动方向
enum DIRECTION
{
UP = 1,
DOWN,
LEFT,
RIGHT
};
//蛇的状态 --正常,撞墙,撞自己,正常退出
enum GAME_STATUS
{
OK,
KILL_BY_WALL,
KILL_BY_SELF,
END_NORMAL
};
//贪吃蛇
typedef struct Snake
{
pSnakeNode _pSnake;//指向蛇头的指针
pSnakeNode _pFood;//指向食物结点的指针
enum DIRECTION _dir;//蛇运动的方向
enum GAME_STATUS _status;//游戏的状态
int _foot_weight;//一个食物的分数
int _score;//总成绩
int _sleep_time;//休息时间,时间越短,速度越快,时间越长,速度越慢
}Snake, * pSnake;
接着,因为我们在后面会打印宽字符,所以可以在test.c中换到本地模式下。游戏分为3个阶段,即
- 初始化游戏
- 运行游戏
- 结束游戏
我们可以根据这3个阶段写3个函数,GameStart,GameRun,GameEnd来处理每个阶段所发生的事情,当然每个阶段,都需要知道蛇的整体状态,所以我们传struct Snake类型的指针即可。
我们了解完这些后可以写一个大体的框架出来。(在test.c文件下)
#include "snake.h"
//实现游戏的测试逻辑
void test()
{
//创建贪吃蛇
Snake snake = { 0 };
//初始化游戏
//1.打印欢迎界面
//2.打印功能介绍
//3.绘制地图
//4.创建蛇
//5.创建食物
//6.设置游戏的相关信息
GameStart(&snake);
//运行游戏
//GameRun();
//结束游戏 - 善后工作
//GameEnd();
}
int main()
{
//适配本地环境
setlocale(LC_ALL, "");
test();
return 0;
}
GameStart的实现
首先,我们需要设置控制台的大小和隐藏光标,具体代码如下:
//设置控制台大小
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、打印欢迎界面
在欢迎界面,我们要向玩家展示游戏的功能介绍,接下来是具体代码的实现。(有详细解释)
//封装一个设置光标位置的函数
void SetPos(short x, short y)
{
//获取标准输出设备的句柄
HANDLE houtput = NULL;
houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//定位光标位置
COORD pos = { x,y };
SetConsoleCursorPosition(houtput, pos);
}
void WelcomeToGame()
{
SetPos(37, 12);//定位光标位置
wprintf(L"%ls",L"欢迎来到贪吃蛇小游戏"); //打印信息
SetPos(41, 25); //再次设置光标位置,这样"请按任意键继续..."会显得美观
system("pause");
system("cls"); //第一个界面完成,跳转到第二个界面
SetPos(30, 12); //定位光标
wprintf(L"用↑,↓,←,→来控制蛇的移动,按F3加速,F4减速\n"); //打印功能介绍
SetPos(30, 13); //定位光标
wprintf(L"加速能够得到更高的分数\n"); //打印功能介绍(不在一起打印是为了看起来美观)
SetPos(41, 25);//定位光标位置,这样"请按任意键继续..."会显得美观
system("pause");
system("cls");//欢迎界面完成,清屏,切到游戏界面,进行接下来的操作
}
给大家看一下效果图:
2、打印地图
在这里我们就需要打印宽字符了,大家可还记得一个宽字符占2个字符和坐标的描述,那么接下来就好处理了。我们假设地图27行,58列(这里大家可以根据自己的想法设计,确保不能比控制台还大,且要注意'列'要是偶数)。
接下来,我们开始绘制地图。我们按上下左右的顺序打印,我们知道一个宽字符占两个字符,所以上下两行只需打印29个'□'即可。左右两列打印27-1-1=25个'□'即可。
直接上代码:
//绘制地图
void CreateMap()
{
//上
for (int i = 0;i < 29;i++)
wprintf(L"%lc", L'□');
//下
SetPos(0, 26);//因为光标位置变了,所以需要重新定位
for (int i = 0;i < 29;i++)
wprintf(L"%lc", L'□');
//左
SetPos(0, 1);
for (int i = 0;i < 25;i++)
wprintf(L"%lc\n", L'□');
//右
for (int i = 0;i < 25;i++)
{
SetPos(56, i+1);
wprintf(L"%lc", L'□');
}
getchar();//方便观察地图是否绘制正确,无实际意义
}
效果图为:
至此,地图绘制完毕。
3、初始化蛇
进入游戏时,我们需要讲蛇的身体展示出来以及和蛇相关的一系列东西,我们来写一个函数来初始化蛇的信息,我们在前面讲蛇的身体使用链表串连起来的。
接下来,请看代码(详细注解):
#define POS_X 24
#define POS_Y 5
//初始化蛇
void InitSnake(pSnake ps)
{
pSnakeNode cur = NULL;
for (int i = 0;i < 5;i++)//初始化蛇的身体为5段
{
cur = (pSnakeNode)malloc(sizeof(SnakeNode));//动态开辟节点
if (cur == NULL)//开辟失败,则返回
{
perror("InitSnake()::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;
}
}
//蛇的5段身体构建完毕接下来就开始打印了
cur = ps->_pSnake;
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc",L'●');
cur = cur->next;
}
//设置贪吃蛇的属性
ps->_dir = RIGHT;//默认向右走
ps->_score = 0;
ps->_foot_weight = 10;
ps->_sleep_time = 200;//单位ms
ps->_status = OK;
}
以上就是初始化蛇的全部步骤了。
4、创建食物
蛇已经初始化完毕,接下来就要处理食物的问题了,食物的生成需要随机,那么食物对应坐标就需要随机生成。
但要注意的是:
- 食物的横坐标必须是2得倍数,因为一个宽字符占两个普通字符。
- 食物的坐标必须在墙体内部,不能在墙体上或墙体外。
- 食物的坐标不能生成在蛇的身上。
因为,我们现在的墙体长为58,宽为27,所以生成食物的坐标的x取值范围为:2-54,y的取值范围为:1-25。
接下来的处理细节,请看代码(有详细注解):
//创建食物
void CreateFood(pSnake ps)
{
//x的范围:2-54
//y的范围:1-25
int x = 0;
int y = 0;
again:
do
{
x = rand() % 53 + 2; //控制x的取值在2-54之间
y = rand() % 25 + 1; //控制y的取值在1-25之间
} while (x % 2 != 0);//利用循环,保证x的值一定是2的倍数
//控制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 fail");
return;
}
pFood->x = x;
pFood->y = y;
pFood->next = NULL;
//打印食物节点
SetPos(x, y);
wprintf(L"%lc",L'★');
ps->_pFood = pFood;
}
至此,GameStart功能全部实现。
GameRun的实现
1、打印帮助信息
在游戏运行过程中,我们要给玩家提示信息,提高游戏的可玩性。
具体代码如下:
void PrintHelpInfo()
{
SetPos(60, 15);
wprintf(L"%ls", L"提示信息:");
SetPos(60, 16);
wprintf(L"%ls",L"1.不能穿墙,不能咬到自己");
SetPos(60, 17);
wprintf(L"%ls",L"2.用↑,↓,←,→来控制蛇的移动");
SetPos(60, 18);
wprintf(L"%ls", L"3.按F3加速,F4减速");
SetPos(60, 19);
wprintf(L"%ls", L"4.按ESC退出游戏,按空格暂停游戏");
}
效果图为:
2、大体逻辑
接下来,就是按键让蛇移动了,我们根据按键不同来控制各种信息。我们可以写一个循环来管理游戏运行时的状态,如果游戏状态为OK时让循环继续,否则循环结束(意味着游戏状态不是OK,即游戏结束)。在循环中,通过按键来改变游戏状态,写蛇移动的逻辑。
下面请看代码(有详细注解):
#define KEY_PRESS(vk) ((GetAsyncKeyState(vk)&1)?1:0)
void GameRun(pSnake ps)
{
//打印帮助信息
PrintHelpInfo();
//检测按键
do
{
//打印总分数和食物的分值
SetPos(60, 10);
wprintf(L"总分数:%d", ps->_score);
SetPos(60, 11);
wprintf(L"当前食物分数:%2d", ps->_foot_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;
//如果按了空格,就暂停游戏,这个逻辑用Pause函数单独实现
else if (KEY_PRESS(VK_SPACE))
Pause();//暂停
//如果按了ESC就正常退出游戏,改变游戏状态为END_NORMAL
else if (KEY_PRESS(VK_ESCAPE))
ps->_status = END_NORMAL;//正常退出游戏
//如果按了F3,就加速,加速时休眠时间变短(但需要有限制,不能一直减),食物权重变大
else if (KEY_PRESS(VK_F3))
{
//加速
if (ps->_sleep_time > 80)
ps->_sleep_time -= 30, ps->_foot_weight += 2;
}
//如果按了F4,就加速,加速时休眠时间变长,食物权重变小(但需要有限制,不能一直减)
else if (KEY_PRESS(VK_F4))
{
//减速
if (ps->_foot_weight > 2)
ps->_sleep_time += 30, ps->_foot_weight -= 2;
}
//这里蛇走一步,这个逻辑用SnakeMove函数来实现
SnakeMove(ps);
//走完一步需要休眠一下
Sleep(ps->_sleep_time);
} while (ps->_status == OK);//游戏状态为OK就说明正常,一直循环下去,否则跳出循环结束游戏
}
好了,我们明白了大体逻辑,现在就开始实现内部的函数吧。
3、游戏暂停
话不多说,直接看代码(有注解):
#define KEY_PRESS(vk) ((GetAsyncKeyState(vk)&1)?1:0)
void Pause() //我们按下空格会进入到这个函数
{
while (1)//写一个死循环代码,直到再次按下空格跳出循环,游戏继续
{
Sleep(100);
if (KEY_PRESS(VK_SPACE))
break;
}
}
4、蛇移动
我们写这个函数之前,先思考一下蛇移动的逻辑,从蛇头开始移动,下一个位置,要么是食物,要么不是食物,如果是食物我们就吃掉它,如果不是食物,我们就向前走一格(这里先不用判断是否下一格是墙或者是自己的身体,我们稍后会有专门的逻辑判断)。
针对有无食物这两种情况不妨统一为下一个位置开辟一个节点,这样方便处理逻辑。因为,如果是食物就把这个新开辟的节点链接上打印即可,如果不是食物就链接这个新开辟的节点然后把最后一个节点的位置打印两个空格即可。所以新开辟一个节点还是有必要的。
我们来看一下具体代码:
//蛇移动
void SnakeMove(pSnake ps)
{
//这里开辟新节点
pSnakeNode pNextNode = (pSnakeNode)malloc(sizeof(SnakeNode));
//判断是否开辟成功
if (pNextNode == NULL)
{
perror("SnakeMove()::malloc fail");
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);
}
4.1、判断蛇头下一个位置是否为食物
//判断下一个坐标是不是食物
bool NextIsFood(pSnakeNode pn, pSnake ps)
{
return pn->x == ps->_pFood->x && pn->y == ps->_pFood->y;
}
4.2、下一个位置是食物就吃掉食物
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",L'●');
cur = cur->next;
}
ps->_score += ps->_foot_weight;//吃掉食物,总分要增加
//重新创建食物
CreateFood(ps);
}
4.3、下一个位置不是食物
void NoFood(pSnakeNode pn, pSnake ps)
{
//头插法
pn->next = ps->_pSnake;
ps->_pSnake = pn;
pSnakeNode cur = ps->_pSnake;
while (cur->next->next)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", L'●');
cur = cur->next;
}
SetPos(cur->next->x, cur->next->y);//将最后一个节点删掉(即打印两个空格)
printf(" ");
free(cur->next);
cur->next = NULL;
}
4.4、检测蛇是否撞墙
判断蛇撞墙就是看蛇头坐标是否和墙的坐标重合就行。
//检测蛇是否撞墙
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;
}
}
4.5、检测蛇是否撞到自己
检测是否撞到自己只需判断蛇头坐标是否与蛇身体的任意坐标相同即可。
//检测蛇是否撞到自己
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;
}
}
这样整个蛇移动逻辑就完成了。
GameEnd的实现
1、打印结束信息
游戏结束逻辑是说明了游戏状态不是OK,那么我们可以利用switch语句来对游戏状态进行判断,然后在合适的打印相关信息。
具体代码如下:
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;
}
2、释放节点
我们知道蛇身是由一个个节点构成的,所以我们要在游戏结束时释放它们,避免造成内存泄漏。
//释放蛇身链表
pSnakeNode cur = ps->_pSnake;
while (cur)
{
pSnakeNode curNext = cur->next;//记录下一个节点
free(cur);//释放当前节点
cur = curNext;
}
至此3大逻辑已经完成。
三、游戏结束后序
当游戏结束后我们如果还想玩,该怎么办?
我们可以用一个do while循环来进行控制。具体代码如下:
//实现游戏的测试逻辑
void test()
{
int ch = 0;
do
{
system("cls");
Snake snake = { 0 };
GameStart(&snake);
//运行游戏
GameRun(&snake);
//结束游戏 - 善后工作
GameEnd(&snake);
//把光标露出来
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(houtput, &CursorInfo);
CursorInfo.bVisible = true;
CursorInfo.dwSize = 20;
SetConsoleCursorInfo(houtput, &CursorInfo);
SetPos(24, 13);
wprintf(L"再来一局吗?(Y/N)");
ch = getchar();
while (getchar() != '\n');//防止'\n'和其他多余的字符捣乱
} while (ch == 'Y' || ch == 'y');
SetPos(0, 30);
}
至此,贪吃蛇游戏完成,当然小伙伴们也可以根据自己的意愿来添加其他功能。
四、贪吃蛇源代码
最后,为大家奉上源代码供大家参考。
1、test.c文件
#include "snake.h"
//实现游戏的测试逻辑
void test()
{
int ch = 0;
do
{
system("cls");
//创建贪吃蛇
Snake snake = { 0 };
//初始化游戏
//1.打印欢迎界面
//2.打印功能介绍
//3.绘制地图
//4.创建蛇
//5.创建食物
//6.设置游戏的相关信息
GameStart(&snake);
//运行游戏
GameRun(&snake);
//结束游戏 - 善后工作
GameEnd(&snake);
//把光标露出来
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(houtput, &CursorInfo);
CursorInfo.bVisible = true;
CursorInfo.dwSize = 20;
SetConsoleCursorInfo(houtput, &CursorInfo);
SetPos(24, 13);
wprintf(L"再来一局吗?(Y/N)");
ch = getchar();
while (getchar() != '\n');
} while (ch == 'Y' || ch == 'y');
SetPos(0, 30);
}
int main()
{
//适配本地环境
setlocale(LC_ALL, "");
srand((unsigned int)time(NULL));
test();
return 0;
}
2、snake.h头文件
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <windows.h>
#include <locale.h>
#include <time.h>
#include <conio.h>
#define POS_X 24
#define POS_Y 5
//蛇的运动方向
enum DIRECTION
{
UP = 1,
DOWN,
LEFT,
RIGHT
};
//蛇的状态 --正常,撞墙,撞自己,正常退出
enum GAME_STATUS
{
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_STATUS _status;//游戏的状态
int _foot_weight;//一个食物的分数
int _score;//总成绩
int _sleep_time;//休息时间,时间越短,速度越快,时间越长,速度越慢
}Snake, * pSnake;
//函数声明
//设置光标位置的函数
void SetPos(short x, short y);
/*+++++++++++++++++++++++++++++++++++++++++++++*/
//游戏初始化
void GameStart(pSnake ps);
//绘制地图
void CreateMap();
//初始化蛇
void InitSnake(pSnake ps);
//创建食物
void CreateFood(pSnake ps);
/*+++++++++++++++++++++++++++++++++++++++++++++*/
//游戏运行
void GameRun(pSnake ps);
//蛇移动
void SnakeMove(pSnake ps);
//判断下一个坐标是不是食物
bool 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);
3、snake.c文件
#include "snake.h"
void KeyFun()
{
//检测是否有按键被按下
while (_kbhit())
{
// 使用 _getch() 获取按下的键,不阻塞程序
int key = _getch();
// 处理按键事件,可以根据需要进行相应的操作
}
}
/*+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*/
//封装一个设置光标位置的函数
void SetPos(short x, short y)
{
//获取标准输出设备的句柄
HANDLE houtput = NULL;
houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//定位光标位置
COORD pos = { x,y };
SetConsoleCursorPosition(houtput, pos);
}
void WelcomeToGame()
{
SetPos(37, 12);
wprintf(L"%ls",L"欢迎来到贪吃蛇小游戏");
SetPos(41, 25);
system("pause");
system("cls");
SetPos(30, 12);
wprintf(L"用↑,↓,←,→来控制蛇的移动,按F3加速,F4减速\n");
SetPos(30, 13);
wprintf(L"加速能够得到更高的分数\n");
SetPos(41, 25);
system("pause");
system("cls");
}
/*+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*/
//绘制地图
void CreateMap()
{
//上
for (int i = 0;i < 29;i++)
wprintf(L"%lc", L'□');
//下
SetPos(0, 26);
for (int i = 0;i < 29;i++)
wprintf(L"%lc", L'□');
//左
SetPos(0, 1);
for (int i = 0;i < 25;i++)
wprintf(L"%lc\n", L'□');
//右
for (int i = 0;i < 25;i++)
{
SetPos(56, i+1);
wprintf(L"%lc", L'□');
}
}
//初始化蛇
void InitSnake(pSnake ps)
{
pSnakeNode cur = NULL;
for (int i = 0;i < 5;i++)
{
cur = (pSnakeNode)malloc(sizeof(SnakeNode));
if (cur == NULL)
{
perror("InitSnake()::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",L'●');
cur = cur->next;
}
//设置贪吃蛇的属性
ps->_dir = RIGHT;//默认向右走
ps->_score = 0;
ps->_foot_weight = 10;
ps->_sleep_time = 200;//单位ms
ps->_status = OK;
}
//创建食物
void CreateFood(pSnake ps)
{
//x的范围:2-54
//y的范围:1-25
int x = 0;
int y = 0;
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 fail");
return;
}
pFood->x = x;
pFood->y = y;
pFood->next = NULL;
SetPos(x, y);
wprintf(L"%lc",L'★');
ps->_pFood = pFood;
}
/*+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*/
//游戏初始化
void GameStart(pSnake ps)
{
//1.打印欢迎界面
//2.打印功能介绍
//3.绘制地图
//4.创建蛇
//5.创建食物
//6.设置游戏的相关信息
//设置控制台大小
system("mode con cols=100 lines=35");
system("title 贪吃蛇");
//隐藏光标
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(houtput, &CursorInfo);
CursorInfo.bVisible = false;
SetConsoleCursorInfo(houtput, &CursorInfo);
//打印欢迎界面+功能介绍
WelcomeToGame();
//绘制地图
CreateMap();
//初始化蛇
InitSnake(ps);
//创建食物
CreateFood(ps);
}
/*+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*/
static void PrintHelpInfo()
{
SetPos(60, 15);
wprintf(L"%ls", L"提示信息:");
SetPos(60, 16);
wprintf(L"%ls",L"1.不能穿墙,不能咬到自己");
SetPos(60, 17);
wprintf(L"%ls",L"2.用↑,↓,←,→来控制蛇的移动");
SetPos(60, 18);
wprintf(L"%ls", L"3.按F3加速,F4减速");
SetPos(60, 19);
wprintf(L"%ls", L"4.按ESC退出游戏,按空格暂停游戏");
}
//判断下一个坐标是不是食物
bool NextIsFood(pSnakeNode pn, pSnake ps)
{
return pn->x == ps->_pFood->x && pn->y == ps->_pFood->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",L'●');
cur = cur->next;
}
ps->_score += ps->_foot_weight;
//重新创建食物
CreateFood(ps);
}
//下一个坐标不是食物
void NoFood(pSnakeNode pn, pSnake ps)
{
//头插法
pn->next = ps->_pSnake;
ps->_pSnake = pn;
pSnakeNode cur = ps->_pSnake;
while (cur->next->next)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", L'●');
cur = cur->next;
}
SetPos(cur->next->x, cur->next->y);
printf(" ");
free(cur->next);
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 fail");
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);
}
#define KEY_PRESS(vk) ((GetAsyncKeyState(vk)&1)?1:0)
static void Pause()
{
while (1)
{
Sleep(100);
if (KEY_PRESS(VK_SPACE))
break;
KeyFun();
}
}
void GameRun(pSnake ps)
{
//打印帮助信息
PrintHelpInfo();
//检测按键
do
{
//打印总分数和食物的分值
SetPos(60, 10);
wprintf(L"总分数:%d", ps->_score);
SetPos(60, 11);
wprintf(L"当前食物分数:%2d", ps->_foot_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->_foot_weight += 2;
}
else if (KEY_PRESS(VK_F4))
{
//减速
if (ps->_foot_weight > 2)
ps->_sleep_time += 30, ps->_foot_weight -= 2;
}
//蛇走一步
SnakeMove(ps);
Sleep(ps->_sleep_time);
KeyFun();
} 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 curNext = cur->next;
free(cur);
cur = curNext;
}
}
五、结语
首先,感谢你看到这里,其次,祝你天天开心!