思路和建议:想要自己实现贪吃蛇这种游戏,我们需要顺着游戏的逻辑来写代码。
首先我们就会想到要打印欢迎界面,它是所有游戏的开端,当然,它也很容易实现。
然后进入游戏主体,我们注意到贪吃蛇由几个重要元素组成:第一是地图,在学会控制光标位置后这个实现起来就很简单,后面具体实现时再讲述;
第二就是食物,食物是随机出现在地图中的,因此我们知道可以用随机数来生成食物。生成了坐标肯定还要打印,所以我们会考虑单独写一个打印函数,来打印地图、食物;
第三,也就是最重要的蛇,蛇要实现多个功能,包括初始化、转向操作、加速、减速、吃到食物后蛇身变长、撞到自己或墙后退出游戏。由于蛇的这些功能可以用一个链表来维护,所以我们可以考虑用单链表来实现这些功能。
要实现这样一个小游戏,我们需要掌握C语言并会熟练使用,Win32 API简单学习使用,链表的原理和使用,下面进行分步讲解:(讲解顺序和我写代码的逻辑顺序一致,代码内容和思路仅供参考)
一、设置控制台基本参数
system("mode con cols=192 lines=54");//设置控制台大小16/9格式
system("title 贪吃蛇游戏");
setlocale(LC_ALL, "");//设置成宽字符输出
HANDLE Handle = GetStdHandle(STD_OUTPUT_HANDLE);//获取控制台信息
CONSOLE_CURSOR_INFO Console_Cursor_Info;//创建结构体
GetConsoleCursorInfo(Handle, &Console_Cursor_Info);//获取结构体信息
Console_Cursor_Info.bVisible = false;//隐藏光标
SetConsoleCursorInfo(Handle, &Console_Cursor_Info);//设置控制台信息
这里主要用了Win32 API的知识,会用即可,思路也很简单,即获取光标信息,改变光标信息,再将光标信息传回控制台使其生效
二、接下来我们会想到打印菜单,这里面就介绍一些游戏规则,按键等要素,这些都是自己把握,可以按照自己的想法来写:
我们可以选择将它封装到一个函数里,如果后续要用到它,直接调用就行了
void Game_Menu()//192 * 54
{
SetCursorPose(87, 20);
wprintf(L"欢迎来到贪吃蛇游戏");
SetCursorPose(87, 40);
system("pause");
system("cls");
SetCursorPose(40, 20);
wprintf(L"操作规则:玩家通过'W''S''A''D'操控一条蛇,蛇不可以撞到墙壁,也不可以撞到自身");
SetCursorPose(40, 22);
wprintf(L"得分规则:正常速度下蛇每吃掉一个食物,就能获得100分,同时蛇也会变长");
SetCursorPose(40, 24);
wprintf(L"玩家可以通过按'L'来减速,每次减速会降低得分10,最低降到10分。玩家也可通过按'P'来加速,每次加速会增加得分10,最高加至200分");
SetCursorPose(40, 26);
wprintf(L"其他按键:开始游戏后按空格键可以暂停游戏,暂停后你可以进一步选择重新开始一局新的游戏;按ESC键直接退出游戏");
SetCursorPose(87, 40);
system("pause");
system("cls");
}
下面算是比较难的一步,因为进入游戏主体后有很多东西需要初始化和处理,很多人会不知道从哪开始,建议按照我博客开头的那种思维方法先自己捋一下逻辑
三、游戏初始化
1.打印地图
宽字符的特点,控制台坐标的概念理解是必须的。
首先我们要知道一个光标格子是矩形,水平方向x坐标间距离是竖直方向y的一半,因此要打印一个正方形的地图需要自己算清楚横坐标和纵坐标的界限,以及你打算打印的墙的层数,x轴建议都以偶数为基本单位,否则会出现错位的现象
这一部分我建议自己拿一张草稿纸,把墙的四个角的坐标,墙的边界坐标标注出来,这对后续很有帮助
void Game_Base_Scene(int Score_All, int Score_Per)
{
int count = 0;//打印墙体的计数器
//先打印列后打印墙,通过光标进行调整
SetCursorPose(0, 2);
for (count = 2; count <= 51; count++)//第一列和第二列一起打印,否则换行会出现覆盖的情况
{
wprintf(L"□□\n");
}
for (count = 2; count <= 51; count++)
{
SetCursorPose(104, count);//只能设置光标进行调整,换行会跳转到下一行的第一列,打印会出现覆盖
wprintf(L"□");
SetCursorPose(106, count);
wprintf(L"□");
}
SetCursorPose(0, 0);
for (count = 0; count < 54; count++)//第一行墙
{
wprintf(L"□");
}
SetCursorPose(0, 1);
for (count = 0; count < 54; count++)//第二行墙
{
wprintf(L"□");
}
SetCursorPose(0, 52);
for (count = 0; count < 54; count++)//倒数第二行墙
{
wprintf(L"□");
}
SetCursorPose(0, 53);
for (count = 0; count < 54; count++)//倒数第一行墙
{
wprintf(L"□");
}
SetCursorPose(112, 16);
wprintf(L"当前总得分:%-3d",Score_All);//总得分,对分数进行格式规范,更美观
SetCursorPose(112, 26);
wprintf(L"当前每个食物的分数:%-3d",Score_Per);//每个食物的得分
SetCursorPose(112, 46);
wprintf(L"通过'W''S''A''D'操控一条蛇,按'L'减速,按'P'加速,不可以撞到墙壁,不可以撞到自身");
SetCursorPose(112, 48);
wprintf(L"正常速度下蛇每吃掉一个食物,就能获得100分,同时蛇也会变长");
SetCursorPose(112, 50);
wprintf(L"每加一次速分数提高10,最多提至200分;每减一次速分数降低10,最低减至10分");
SetCursorPose(112, 52);
wprintf(L"按空格键停止游戏,按ESC退出游戏");
}
除去墙,我的地图是50 * 50,实际情况可以根据自己的进行修改。
同时,或许我们还会注意到我这个函数传了Score_All, Score_Per这两个参数,这是因为蛇可以加速或减速,针对不同的速度有不同的分数,传过来这两个参数并在地图上展示出来,让玩家能实时看到自己的单个食物得分、总分。
2.创建蛇的头部结点并打印蛇
其实我原本的思路是先创建食物、打印食物的,但我注意到食物不能在蛇的身体中创建,而我还没有创建蛇,所以第二步我就该为了创建蛇。自己写的时候难免会有遇到之前忽略的细节,见招拆招是最好的解决方法,因为大体逻辑已定,我们代码逻辑整体也不会被带偏。
typedef struct Snake_SLTNode
{
int x;//横坐标
int y;//纵坐标
struct Snake_SLTNode* next;
}Snake_SLTNode;
在自己写这种项目时多用结构体,typedef来帮助自己的代码更易被理解
Snake_SLTNode* Create_Snake_SLTNode(int x, int y)
{
Snake_SLTNode* NewNode = (Snake_SLTNode*)malloc(sizeof(Snake_SLTNode));
assert(NewNode);
NewNode->x = x;
NewNode->y = y;
NewNode->next = NULL;
return NewNode;
}
Snake_SLTNode* Get_Snake_Head()
{
int y = rand() % 50 + 2;//2到51
int x = (rand() % 50 + 2) * 2;//4到102
Snake_SLTNode* NewNode = Create_Snake_SLTNode(x, y);
return NewNode;
}
这里我封装了两个函数,考虑到蛇的长度会变,会有新的结点添入进来,所以我将创建结点那一步单独封装了一个函数,后面可以直接调用。
void Game_Snake_Print(Snake_SLTNode* Head)
{
assert(Head);//必须先创建头,再调用这个函数打印
Snake_SLTNode* prev = Head;
SetCursorPose(prev->x, prev->y);
wprintf(L"○");//打印头部用空心圆,身体用实心圆
prev = prev->next;
while (prev)
{
SetCursorPose(prev->x, prev->y);
wprintf(L"●");//打印头部用空心圆,身体用实心圆
prev = prev->next;
}
}
这里借助链表就可以将蛇打印出来,但是蛇是会动的,蛇每动一次先前打印的结点应该全部被清理掉,不然会发现打印会混乱。但是我们如果使用system("cls")就会发现地图也会被清理掉,如果反复打印会出现地图闪烁的情况,所以我们要在后面处理这个情况。
3.创建食物并打印
typedef struct Food_Position
{
int x;//横坐标
int y;//纵坐标
}Food_Position;
食物用坐标来表示
Food_Position* Get_Food_Pose(Snake_SLTNode* Head)
{
assert(Head);
int count = 0;//用来判断和蛇的结点是否有重合
Snake_SLTNode* prev = Head;
while (1)
{
int y = rand() % 50 + 2;//2到51
int x = (rand() % 50 + 2) * 2;//4到102
while (prev)
{
if (prev->x == x && prev->y == y)
{
count = 1;
break;
}
prev = prev->next;
}
if (!count)
{
Food_Position* NewFood = (Food_Position*)malloc(sizeof(Food_Position));
assert(NewFood);
NewFood->x = x;
NewFood->y = y;
return NewFood;
}
}
}
void Game_Food_Print(Food_Position* Food_Position)
{
assert(Food_Position);
SetCursorPose(Food_Position->x, Food_Position->y);
wprintf(L"★");//用★代表食物
}
四、处理按键情况
这里会用到另一个Win32 API函数,用于检测按键的点按情况,这个会用即可。在这里我选择用宏替换使代码更易读
//判断short类型最低位是不是1来判断是不是被按过,用&操作获取最低位信息,1是按过,0是没按过
#define PRESS_W (GetAsyncKeyState(0x57) & 0x1)//W键,上行
#define PRESS_S (GetAsyncKeyState(0x53) & 0x1)//S键,下行
#define PRESS_A (GetAsyncKeyState(0x41) & 0x1)//A键,左行
#define PRESS_D (GetAsyncKeyState(0x44) & 0x1)//D键,右行
#define PRESS_L (GetAsyncKeyState(0x4C) & 0x1)//减速
#define PRESS_P (GetAsyncKeyState(0x50) & 0x1)//加速
#define PRESS_SPACE (GetAsyncKeyState(VK_SPACE) & 0x1)//空格键,暂停游戏
#define PRESS_ESC (GetAsyncKeyState(VK_ESCAPE) & 0x1)//ESC键,退出游戏
#define PRESS_ONE ((GetAsyncKeyState(VK_NUMPAD1) & 0x1) | (GetAsyncKeyState(0x31) & 0x1))//按到了键盘的1,对数字键盘和横着的键盘都进行检测,二者有其一即可
#define PRESS_ZERO ((GetAsyncKeyState(VK_NUMPAD0) & 0x1) | (GetAsyncKeyState(0x30) & 0x1))//按到了键盘0
设置完毕后,我们就可以开始对蛇进行操作了
五、蛇的移动和加减速
在这里,我们发现我们要给蛇移动的属性,就是要让链表的坐标时刻保持变化,在按下下一个改变方向的键之前,蛇要保持之前的移动状态,因此我定义了一个枚举变量
typedef enum Game_State
{
wait,
up = 1, down, left, right,
die_self, die_wall,
Pause, Exit
}Game_State;
在相应状态下,蛇进行相应的操作,只有按下改变方向的按键,蛇的状态就会被改变。我们同时要知道,蛇的结点的坐标每变换一次,相当于蛇前进一步,要控制蛇的速度,我们需要控制坐标变换的速度。我们知道计算机是很快的,要控制它的速度应用Sleep进行调节。
在理解上面的处理方法后,我们就可以针对上下左右进行不同的处理了:
1.先封装一个函数,专门处理蛇的运动状态,同时要注意蛇在向左运动时不能调头向右运动,蛇在判定为死亡后也不能运动,要注意if要把这些情况涵盖全面,否则程序会有Bug。
这里注意,我还定义了蛇死亡的两种状态,但我们还没有针对蛇撞墙和撞自身做出判定,但也不影响这块的代码实现
void Game_Branch(int* Sleep_Time, int* Score_Per, Game_State* state)
{
assert(state);
if (PRESS_W && *state != down && *state != die_self && *state != die_wall)
*state = up;
if (PRESS_S && *state != up && *state != die_self && *state != die_wall)
*state = down;
if (PRESS_A && *state != right && *state != die_self && *state != die_wall)
*state = left;
if (PRESS_D && *state != left && *state != die_self && *state != die_wall)
*state = right;
if (PRESS_L)
{
if (*Score_Per > 10)
{
*Score_Per -= 10;
*Sleep_Time += 15;
}
}
if (PRESS_P)
{
if (*Score_Per < 200 && *Sleep_Time >= 10)
{
*Score_Per += 10;
*Sleep_Time -= 15;
}
}
if (PRESS_SPACE)
{
*state = Pause;
}
if (PRESS_ESC)
{
*state = Exit;
}
}
然后针对每种状态进行处理:中间插入switch语句使逻辑更清晰
void Game_Move(Snake_SLTNode* phead, Game_State state, int* plast_x, int* plast_y)
{
assert(phead);
switch (state)
{
case up:
Game_Snake_Up(phead, plast_x, plast_y);
break;
case down:
Game_Snake_Down(phead, plast_x, plast_y);
break;
case left:
Game_Snake_Left(phead, plast_x, plast_y);
break;
case right:
Game_Snake_Right(phead, plast_x, plast_y);
break;
default:
break;
}
}
void Snake_Body_Move(Snake_SLTNode* phead, int tmp_x, int tmp_y, int* plast_x, int* plast_y)
{
assert(phead);
Snake_SLTNode* prev = phead;
while (prev->next)//将蛇身全部移动
{
prev = prev->next;
prev->x = prev->x ^ tmp_x;//将prev->x和tmp_x进行交换
tmp_x = prev->x ^ tmp_x;
prev->x = prev->x ^ tmp_x;
prev->y = prev->y ^ tmp_y;//将prev->y和tmp_y进行交换
tmp_y = prev->y ^ tmp_y;
prev->y = prev->y ^ tmp_y;
}
SetCursorPose(tmp_x, tmp_y);//移动的时候将末尾的字符删掉
printf(" ");
*plast_x = tmp_x;
*plast_y = tmp_y;
}
void Game_Snake_Up(Snake_SLTNode* phead, int* plast_x, int* plast_y)
{
assert(phead);
int tmp_x = phead->x;//存储上一个结点
int tmp_y = phead->y;
phead->y--;//往上走
Snake_Body_Move(phead, tmp_x, tmp_y, plast_x, plast_y);
}
void Game_Snake_Down(Snake_SLTNode* phead, int* plast_x, int* plast_y)
{
assert(phead);
int tmp_x = phead->x;//存储上一个结点
int tmp_y = phead->y;
phead->y++;//往下走
Snake_Body_Move(phead, tmp_x, tmp_y, plast_x, plast_y);
}
void Game_Snake_Left(Snake_SLTNode* phead, int* plast_x, int* plast_y)
{
assert(phead);
int tmp_x = phead->x;//存储上一个结点
int tmp_y = phead->y;
phead->x -= 2;//向左走
Snake_Body_Move(phead, tmp_x, tmp_y, plast_x, plast_y);
}
void Game_Snake_Right(Snake_SLTNode* phead, int* plast_x, int* plast_y)
{
assert(phead);
int tmp_x = phead->x;//存储上一个结点
int tmp_y = phead->y;
phead->x += 2;//向右走
Snake_Body_Move(phead, tmp_x, tmp_y, plast_x, plast_y);
}
注意,在Snake_Body_Move这个函数里我就顺便处理了蛇的打印覆盖的情况,因为每次移动结点是在这个函数里进行的,可以非常容易接触到要覆盖掉的结点的坐标。我把那个要覆盖的坐标对应的区域用空格来打印,视觉上没有瑕疵,也不用每次都用system("cls")来处理了。当然,每个人都有自己的处理办法,这里只是提供一种思路。
六、蛇吃到食物后的处理
蛇吃到食物后,尾部会增加一个结点,原本食物的结点应该被销毁,创建一个新的食物结点并打印,可以用一个函数来进行处理
void Game_Eat(Snake_SLTNode* phead, Food_Position** ppfood, int last_x, int last_y, int* Score_All, int Score_Per)
{
assert(phead && *ppfood && ppfood);
int count = 0;
Snake_SLTNode* new = phead;
if (phead->x == (*ppfood)->x && phead->y == (*ppfood)->y)//只会是头吃到食物,此时食物已经被吃掉了
{
free(*ppfood);
*Score_All += Score_Per;//按规则加分
Snake_SLTNode* NewNode = Create_Snake_SLTNode(last_x, last_y);//函数中已经检查过是不是空指针了,下面就不需要断言了
while (new->next)
{
new = new->next;
}
new->next = NewNode;//在尾巴处生成一个新的结点
*ppfood = Get_Food_Pose(phead);//创建一个新的食物
assert(*ppfood);
}
}
七、蛇的死亡判定
void Game_Die(Snake_SLTNode* phead, Game_State* state)
{
assert(phead);
Snake_SLTNode* prev = phead;
while (prev->next)
{
prev = prev->next;//先找到除头以外的下一个结点
if (phead->x == prev->x && phead->y == prev->y)//说明撞到自己了
{
*state = die_self;
return;//接下来就不要判断撞没撞到墙了,没意义
}
}
if (phead->x < 4 || phead->x > 102 || phead->y < 2 || phead->y > 51)//头一定最先撞到墙
{
*state = die_wall;
}
}
截至目前贪吃蛇的基本实现就完成了,我展示的是核心代码,中间还有很多逻辑没有给出。这样一个小游戏能够很好地训练逻辑能力,代码能力,我这样一个游戏写完差不多有700多行代码,其中C语言的语法几乎接触了个遍,也训练了链表的使用。如果希望提高自己的综合实力,尝试运用已有知识做一些小项目是很有帮助的。