贪吃蛇是一款人人皆知的经典游戏,本篇文章使用C语言在Windows控制台环境下简单模拟实现贪吃蛇小游戏。
这篇博文需要读者熟悉C语言的指针、结构体、枚举、动态内存管理、以及数据结构的单链表等。补充知识有Windows操作系统为应用程序提供的功能接口函数Win32 API。
API对应的英文单词是(Application Programming Interface),是Windows操作系统提供的32位平台应用程序接口,就叫做Win32API,是各种接口的集合。
为了实现贪吃蛇游戏,我们需要学习一些应用程序接口,借这些接口的功能来实现游戏效果,比如光标的隐藏,光标的定位。
在Windows中,上面是控制台应用程序,在Linux经常被叫做终端,我们写的贪吃蛇游戏打算在这个应用程序上运行。
在创建空项目时,创建了一个控制台程序。(博主使用VS2019编译器)
在前面反复提控制台,就是为了告诉读者控制台是一个应用程序,而Win32API就是提供给应用程序使用的函数接口,可以达到开启视窗、描绘图形、使用周边设备等目的。
GetStdHandle是一个Windows API函数,这个函数可以从标准设备(标准输入、标准输出、标准错误)中获取一个句柄,控制台是一个标准输出设备。
每个设备都有一个句柄,并且句柄值不同。使用相应设备的句柄就可以控制这个设备。
以下这句代码,可以获取控制台的句柄(注意包含头文件<windows.h>)
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
该函数的参数有两个(1.句柄,2.光标类型结构体变量的地址),作用是获取句柄设备上的光标信息放到结构体变量里去。
CONSOLE_CURSOR_INFO是Windows操作系统提供的一个描述光标的结构体类型,成员变量dwSize表示光标的大小,bVisible表示光标的可见性,当bVisible被赋值成false时,光标可以实现隐藏。
创建一个光标结构体类型变量,使用GetConsoleCursorInfo这个函数获取控制台上的光标信息,放到我们创建的结构体变量中。
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);//获取控制台句柄
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(hOutput, &CursorInfo);//把控制台的光标信息存入光标结构体变量CursorInfo中
设置句柄设备上的光标。
第一个参数是句柄,第二个参数是光标结构体类型变量的地址。意思是:将句柄设备上的光标设置成第二个参数传的光标。
于是通过1.2和1.3的组合,就可以设置控制台的光标了!代码如下:
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(hOutput, &CursorInfo);
//把可见性设为不可见,并设置进控制台
CursorInfo.bVisible = false;
SetConsoleCursorInfo(hOutput, &CursorInfo);
注意,我们必须获取控制台光标信息存到变量中,改变该变量的可见性,再设置进控制台。
不能单单创建一个光标变量,设置不可见性就设置进控制台。简而言之就是以下代码无法有效隐藏控制台光标:
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
CursorInfo.bVisible = false;
SetConsoleCursorInfo(hOutput, &CursorInfo);//error
设置光标在控制台的位置。当我们输出一句“hello world”的时候,文字默认在左顶角(原点)开始输出,如何让文字在我们想要的位置输出呢?
为此,我们先来谈谈控制台上的坐标系吧。
从原点出发,向右为x轴、向下为y轴、逐渐增大。在Windows中使用COORD这个结构体来表示坐标。
创建这个坐标结构体类型变量,比如:
COORD pos = {3, 4};//这个坐标是3列4行的意思,x是竖,y是横。
接着使用SetConsoleCursorPosition这个函数可以做到设置光标位置,参数如下:
? 在句柄对应设备上,设置COORD类型变量为打印起始点。
//封装成这个函数,传参x和y,就可以指定控制台位置输出了。
void SetPos(int x, int y)
{
COORD pos = { x, y };
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleCursorPosition(hOutput, pos);
}
// 比如,SetPos(64, 15)把控制台光标设置在64列15行处,此时如果打印“hello world”,就不是在左顶角打印了。
这是一个判断按键情况的函数,键盘上每一个键都有一个对应的虚拟键值,请看下图:
GetAsyncKeyState函数接收一个虚拟键值,然后返回一个short类型的数值,如果这个short类型(16个bit位)的最低位为1,则说明这个虚拟键值对应的键被按过。
#define KEY_PRESS(VK) ((GetAsyncKeyState(VK) & 0x1) ? 1 : 0)
GetAsyncKeyState(VK)返回值按位与1,如果结果为真,则返回1,说明该键被按过,否则为0。
计算机语言早期是美国以英语语种设计的语言,所以对其它国家语言兼容性不好,随着国际化越来越加深,计算机语言也支持了本地化(使用者所在地区的语言习惯)比如金钱符号本地化前是$,本地化后¥,以及日期的表示习惯。
对于本文而言,最重要的是宽字符的打印。
英文字母占控制台屏幕缓冲区一个字符位,而宽字符它占两个字符位。要使用宽字符,需要使用setlocale函数设置本地环境。
category是setlocale的第一参数,是本地化的选项。LC_ALL将所有选项都本地化,还有其它单个选项,比如LC_TIME就只本地化时间。
locale是setlocale的第二个参数,只有两个选项,“C”或“ ”。双引号“ ”是执行本地化,而“C"是默认。
于是写上下面这句代码就可以进行本地化:
//设置本地环境
setlocale(LC_ALL, "");
以上就是写贪吃蛇需要的Win32API接口函数的学习,下面进入游戏设计环节。
贪吃蛇游戏是一条蛇在围起来的地图里走动,通过↑、↓、←、→键控制蛇的行走方向,吃地图里的食物逐渐变长,分数累增的游戏。
这样,我们设计蛇头用宽字符○表示、蛇的身体用●、地图上的墙用□、食物使用★。
期望:一开始在一个大小合适的控制台窗口,在合适的位置打印信息。告诉玩家这是贪吃蛇游戏、规则介绍、打印地图、初始化蛇等。
接着是游戏运行界面,有帮助信息、总分数的变化、控制蛇在地图里走动,蛇吃食物等。
?最后游戏结束后给我们反馈游戏因为什么原因结束的,是撞墙了?还是吃到自己?
从这个期望中,我们分三部分:分别是游戏之前的准备(GameStart),游戏运行(GameRun)和游戏结束(GameEnd)。
蛇是由一个个结点链接起来的整体,想要在控制台上准确打印出一条蛇,结点里存的信息应是用来表示坐标的,于是:
typedef struct SnackNode
{
//x、y组合标识控制台上唯一的坐标
int x;
int y;
struct SnackNode* next;
}SnackNode, *pSnackNode;
但是我们玩的是蛇,而不是一个一个结点,这时我们思考游戏在运行的时候,怎样能控制整条蛇、吃食物的总分数、蛇的速度、蛇的方向等等。
通过再封装一个结构体,完成对以上的整体控制:
enum DIRECTION
{
UP = 1,
DOWN,
LEFT,
RIGHT
};
enum GAME_STATUS
{
OK,//蛇正常运行
END_NORMAL,//家里有事,不玩游戏了,按Esc正常退出
KILL_BY_WALL,//撞墙了
KILL_BY_SELF//咬到自己了
};
typedef struct Snack
{
pSnackNode _pSnack;//蛇头结点控制整条蛇
pSnackNode _pFood;//维护食物的指针
int _Score;
int _FoodWeight;
int _SleepTime;//控制蛇的速度
enum DIRECTION _Dir;//表示蛇的行走方向
enum GAME_STATUS _Status;//蛇的状态
}Snack, *pSnack;
这里的*pSnack的意思是:相当于typedef struct Snack *(指针)重定义为pSnack,上面蛇结点指针也是一样的。
可能不熟悉贪吃蛇的读者现在不太理解,为什么蛇结构里需要有维护食物的指针,往下看就明白了。
现在蛇结构蓝图设计好了,开始写代码!
Test.c文件用来测试,Snack.c文件用来写函数实现,Snack.h文件写头文件、写函数声明以及定义标识符、宏等。
现在进入GameStart的实现部分(Snack.c),按照预先的期望在大小合适的控制台上打印欢迎信息。
为了方便讲解,博主在程序中会加入让运行逻辑停下来的getchar()函数。
注意:如果读者的控制台大小无法设置,可能是控制台的设置问题,请按下面步骤进行解决。
title让控制台标题变为贪吃蛇只在程序运行时生效,当程序结束后,标题便不再是我们设置的了。
打印欢迎界面的时候,文字在屏幕中间显示是比较好的,而且最好不要有光标在控制台上一闪一闪的。
所以在这里需要前面的两步铺垫:如何隐藏光标、如何控制光标位置。
void SetPos(int x, int y)
{
COORD pos = { x, y };
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleCursorPosition(hOutput, pos);
}
void WelComeToGame()
{
//隐藏光标
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO Cursor;
GetConsoleCursorInfo(hOutput, &Cursor);
Cursor.bVisible = false;
SetConsoleCursorInfo(hOutput, &Cursor);
SetPos(40, 14);
printf("欢迎来到贪吃蛇小游戏");
SetPos(80, 27);
system("pause");
//清屏
system("cls");
SetPos(36, 14);
printf("使用↑、↓、←、→键控制蛇的移动");
SetPos(80, 27);
system("pause");
//为下面打印地图做好准备
system("cls");//清完屏后,光标会到原点
}
system(“pause”)是让程序暂停下来,并且会自发在控制打印出“请按任意键继续. . .”,所以定位完“欢迎来到…”后,要给这句话再定位一下。
接着再介绍一下规则:
到这里这个函数就完成了。下面到了游戏界面,我们要打印地图。
如果行列设置不合适,显示有问题,读者可以自行设置。
创建地图,墙使用的宽字符是□,并且要执行本地化,支持宽字符的打印。
宽字符占两个字符位(列),但是和ab一样都只占一行,使用wprintf打印宽字符,格式串前面加L,字符前面加L。
控制台屏幕是100列30行,那么我们就创建一个58列28行的地图吧!
void CreateMap()
{
setlocale(LC_ALL, "");
int i = 0;
//上
for (i = 0; i <= 56; i += 2)
{
//#define WALL L'□'
wprintf(L"%lc", WALL);
}
//下
SetPos(0, 27);
for (i = 0; i <= 56; i += 2)
{
wprintf(L"%lc", WALL);
}
//左
for (i = 1; i <= 26; i++)
{
SetPos(0, i);
wprintf(L"%lc", WALL);
}
//右
for (i = 1; i <= 26; i++)
{
SetPos(56, i);
wprintf(L"%lc", WALL);
}
//上图中的getchar()是为了不让程序结束
//结束时打印的信息会覆盖下面的墙
}
地图创建好后,初始化出一条蛇并打印在地图上。
pSnackNode BuySnackNode(int x, int y)
{
pSnackNode tmp = (pSnackNode)malloc(sizeof(SnackNode));
if (tmp == NULL)
{
perror("BuySnackNode malloc fail");
exit(-1);
}
tmp->x = x;
tmp->y = y;
tmp->next = NULL;
return tmp;
}
void InitSnack(pSnack ps)
{
assert(ps);
//创建蛇结点并连起来成“蛇”
int i = 0;
pSnackNode cur = NULL;
for (i = 0; i < 5; i++)
{
//#define POS_X 24 蛇尾的起点坐标POS_X和POX_Y
//#define POS_Y 5
cur = BuySnackNode(POS_X + i * 2, POS_Y);
//单链表头插法
if (ps->_pSnack == NULL)
{
ps->_pSnack = cur;
}
else
{
cur->next = ps->_pSnack;
ps->_pSnack = cur;
}
}
//打印蛇身
cur = ps->_pSnack;
int count = 0;
while(cur)
{
SetPos(cur->x, cur->y);
//第一次进来打印蛇头符号
if (!(count++))
{ //#define HEAD L'○'
wprintf(L"%lc", HEAD);
}
else
{ //#define BODY L'●'
wprintf(L"%lc", BODY);
}
cur = cur->next;
}
//其它设置
ps->_pFood = NULL;//食物还没创建,先赋空指针
ps->_Score = 0;
ps->_FoodWeight = 10;
ps->_SleepTime = 150;//程序暂停时间,控制蛇的速度
ps->_Dir = RIGHT;//初始蛇的方向为右
ps->_Status = OK;//蛇运行状态是正常的
}
创建食物,本质上也是创建蛇结点,蛇吃食物身体变长相当于把食物这个结点链接到蛇上。
食物的创建是有约束的:食物的x坐标应该和蛇一样,都是2的倍数。x列0-57总共58个,每个宽字符占2个字符位,食物也是宽字符,除去0-1和56-57是墙,2到54是食物的x坐标。
食物行坐标在1-26之间,宽字符行与普通字符都是占一行,0行和27行被墙占了,食物不能与墙有重叠的。
还有一点是,食物不要与蛇的身体重合并且任意生成,以上这些约束要留意,下面开始写代码:
void CreateFood(pSnack ps)
{
assert(ps);
int x = 0;
int y = 0;
again:
do
{ //随机数的使用,在主函数里设置一次生成起点
x = rand() % 53 + 2;//取模53得到0~52的余数+2->2~54
y = rand() % 26 + 1;//产生1~26之间的随机数
} while (x % 2 != 0);//x是奇数重新生成坐标
pSnackNode cur = ps->_pSnack;
while (cur)
{
//判断蛇结点与食物结点是否重合
if (cur->x == x && cur->y == y)
{
goto again;
}
cur = cur->next;
}
//到这里,食物结点坐标合法
pSnackNode FoodNode = BuySnackNode(x, y);
//打印食物在地图上的位置
SetPos(x, y);
wprintf(L"%lc", FOOD);//#define FOOD L'★'
//出了创建食物的作用域,食物结点将丢失,于是使用蛇结构食物指针维护
ps->_pFood = FoodNode;
}
到了这一步,游戏的所有准备工作都做好了,进入游戏阶段。
游戏玩耍阶段,控制台左边是蛇在地图里走动,右边设置分数变化和按键说明的信息,比如:按空格键暂停、按Esc键正常退出等等。
void GameHelpInfo()
{
SetPos(64, 14);
printf("1.使用↑.↓.←.→键控制蛇的移动");
SetPos(64, 15);
printf("2.按F2键加速、F3减速");
SetPos(64, 16);
printf("3.蛇不能撞墙,不能咬到自己");
SetPos(64, 17);
printf("4.Space——暂停、Esc——正常退出");
SetPos(84, 24);
printf("@啊苏");
}
因为分数是时刻变化的,所以获得分数不在帮助信息里打印,细想什么是跟随的游戏一直在变的呢?没错就是玩家的按键逻辑,将分数的打印放在按键里。
判断按键的情况在下面的代码中进行讲解:
void Pause()
{
while (1)
{
Sleep(100);
if (PRESS_KEY(VK_SPACE))
{
break;
}
}
}
void GameRun(pSnack ps)
{
assert(ps);
//打印帮助信息
GameHelpInfo();
//判断按键情况
do
{
SetPos(64, 10);
printf("获得的分数:%-4d", ps->_Score);
SetPos(64, 11);
printf("每个食物的分数:%-2d", ps->_FoodWeight);
//键盘上的↑键对应的虚拟值是VK_UP这个常数
if (PRESS_KEY(VK_UP) && ps->_Dir != DOWN)
{ //#define PRESS_KEY(VK) ((GetAsyncKeyState(VK)&0x1) ? 1 : 0)
ps->_Dir = UP;
}
else if (PRESS_KEY(VK_DOWN) && ps->_Dir != UP)
{ //想让蛇的方向朝下,前提是蛇原型方向不能是朝上走的
ps->_Dir = DOWN;
}
else if (PRESS_KEY(VK_LEFT) && ps->_Dir != RIGHT)
{
ps->_Dir = LEFT;
}
else if (PRESS_KEY(VK_RIGHT) && ps->_Dir != LEFT)
{
ps->_Dir = RIGHT;
}
else if (PRESS_KEY(VK_F2))
{
//最多加速三次(从设定的速度开始算)
if (ps->_SleepTime > 30)
{
//_SleepTime控制蛇的速度,越短蛇走的越快
ps->_SleepTime -= 40;
ps->_FoodWeight += 5;
}
}
else if (PRESS_KEY(VK_F3))
{
//最多减速一次(从设定的速度开始算)
if (ps->_SleepTime < 190)
{
ps->_SleepTime += 40;
ps->_FoodWeight -= 5;
}
}
else if (PRESS_KEY(VK_ESCAPE))
{
ps->_Status = END_NORMAL;
break;
}
else if (PRESS_KEY(VK_SPACE))
{
Pause();//暂停函数
}
} while (ps->_Status == OK);//只有蛇正常运行时,才能正常让玩家按键控制方向
}
读者可以在搜索引擎上找GetAsyncKeyState这个函数,然后里面有关于虚拟键值的超链接,点击跳转即可查看。
这么多按键是代码看起来很复杂,但其逻辑并不难理解,但cpu处理速度很快,不用担心处理不过来。
然后睡眠时间现在就起作用了。
在判断按键后,用于Sleep函数让程序停下来一段时间,_SleepTime = 150,150是毫秒。
在短暂的停止后,蛇开始移动!
//判断蛇下一位置是否为结点
int NextIsFood(pSnack ps, pSnackNode pnext)
{ //食物指针的用处体现了,在游戏期间,食物指针能很好的维护着食物,防止内存泄漏
if (pnext->x == ps->_pFood->x && pnext->y == ps->_pFood->y)
{
//如果蛇结点里的食物坐标与蛇下一步走的位置相同 返回1确认是食物
return 1;
}
else
{
//返回0说明蛇的下一位置不是食物
return 0;
}
}
//注意食物结点的坐标与预创建的结点坐标一致,但不是同一个结点!
void EatFood(pSnack ps, pSnackNode pnext)
{
assert(ps);
//预创建的结点成为蛇头,
pnext->next = ps->_pSnack;
ps->_pSnack = pnext;
//然后打印蛇
pSnackNode cur = ps->_pSnack;
int count = 0;
while (cur)
{
SetPos(cur->x, cur->y);
if (!(count++))
{
wprintf(L"%lc", HEAD);
}
else
{
wprintf(L"%lc", BODY);
}
cur = cur->next;
}
//获得分数
ps->_Score += ps->_FoodWeight;
//释放食物结点,食物结点
free(ps->_pFood);
ps->_pFood = NULL;
//重新生成食物,这个创建食物的函数会打印食物在地图上
CreateFood(ps);
}
void NotFood(pSnack ps, pSnackNode pnext)
{
assert(ps);
//不是食物也要把预创建的结点链接进来成为蛇头
pnext->next = ps->_pSnack;
ps->_pSnack = pnext;
//把蛇尾给去掉保持长度不变,使用前后指针,打印蛇身
pSnackNode cur = ps->_pSnack;
pSnackNode curPrev = ps->_pSnack;
int count = 0;
while (cur->next)
{
SetPos(cur->x, cur->y);
if (!(count++))
{
wprintf(L"%lc", HEAD);
}
else
{
wprintf(L"%lc", BODY);
}
curPrev = cur;
cur = cur->next;
}
//到这里cur是旧蛇尾,要把这里的宽字符打印成空格,打印空格覆盖掉地图上的宽字符
SetPos(cur->x, cur->y);
printf(" ");
//释放旧蛇尾
free(cur);
//把新的蛇尾next指针赋空
curPrev->next = NULL;
}
void SnackMove(pSnack ps)
{
assert(ps);
//蛇的移动选择预开辟下一位置结点方法
pSnackNode pNext = NULL;
pSnackNode pHead = ps->_pSnack;//旧蛇头
//根据蛇的方向,选择下一结点的位置
switch (ps->_Dir)
{ //pNext是预创建的结点!
case UP:
pNext = BuySnackNode(pHead->x, pHead->y - 1);//上的话,列不变行减1
break;
case DOWN:
pNext = BuySnackNode(pHead->x, pHead->y + 1);
break;
case LEFT:
pNext = BuySnackNode(pHead->x - 2, pHead->y);//左是行不变,列减2,因为宽字符占两列
break;
case RIGHT:
pNext = BuySnackNode(pHead->x + 2, pHead->y);
break;
}
//蛇方向下一个结点有两种情况:是食物和不是食物
if (NextIsFood(ps, pNext))
{
//是食物就把食物吃了,把pNext结点链接进蛇并成为蛇头
EatFood(ps, pNext);
}
else
{
NotFood(ps, pNext);
}
}
到这里,玩家就能正常在使用上下左右等键控制蛇的移动,加减速、暂停等。接下来要处理蛇在运行时,状态从OK变为不Ok的逻辑。
void KillByWall(pSnack ps)
{
assert(ps);
if (ps->_pSnack->x == 0 ||
ps->_pSnack->x == 56 ||
ps->_pSnack->y == 0 ||
ps->_pSnack->y == 27)
ps->_Status = KILL_BY_WALL;
}
void KillBySelf(pSnack ps)
{
assert(ps);
pSnackNode cur = ps->_pSnack->next;
//蛇头以后的结点与蛇头相比是否有坐标一致的
while (cur)
{
if (cur->x == ps->_pSnack->x && cur->y == ps->_pSnack->y)
{
ps->_Status = KILL_BY_SELF;
break;
}
cur = cur->next;
}
}
到这里,蛇的逻辑就完整了,但是游戏结束后,还没能得到很好的善后处理,比如链表的释放、告诉玩家是怎么游戏结束的等等。
下面进入到游戏结束环节:
游戏结束后,在合适的位置打印信息给玩家:
void GameEnd(pSnack ps)
{
assert(ps);
SetPos(15, 10);
switch (ps->_Status)
{
case END_NORMAL:
printf("主动退出,游戏结束");
break;
case KILL_BY_WALL:
printf("撞到墙了,游戏结束");
break;
case KILL_BY_SELF:
printf("咬到自己,游戏结束");
break;
}
//释放蛇
pSnackNode cur = ps->_pSnack;
while (cur)
{
pSnackNode curnext = cur->next;
free(cur);
cur = curnext;
}
}
程序在结束时有这句话,我们可以在程序结束前定好光标位置,让这句话不要影响游戏效果。
除了这个之外,游戏最重要的一个再来一局逻辑还没有设计,我们把最后的坑补上。
void Test()
{
int input = 0;
do
{
Snack s = { 0 };
//游戏开始
GameStart(&s);
//游戏运行
GameRun(&s);
//游戏结束
GameEnd(&s);
SetPos(15, 12);
printf("是否想要再来一局(Y/N):");
input = getchar();
getchar();//清空输入缓冲区
}while(input == 'Y' || input == 'y');
SetPos(0, 28);
}
到这里,贪吃蛇小游戏实现完毕!
希望这篇博文对想实现贪吃蛇小游戏的读者有帮助。