上篇win32
中篇设计与分析
我们最终的贪吃蛇界面是这个样子,可以发现这和之前写的C语言项目的最大不同就在于文字不是依次排列的,那我们的地图应该如何布置呢?
这里回顾一下控制台窗口的一些知识,如果想在控制台的窗口中指定位置输出信息,我们得知道该位置的坐标,所以首先介绍一下控制台窗口的坐标知识。
控制台窗口的坐标如下所示,横向的是X轴,从左向右依次增长,纵向是Y轴,从上到下依次增长。
在游戏地图上,我们打印墙体使用宽字符□,打印蛇使用宽字符●,打印食物使用宽字符★(这些字符都可以在输入法中打出来)
普通的字符是占一个字节的,这类宽字符是占用2个字节。
这里再简单的讲一下C语言的国际化特性相关的知识,过去C语言并不适合非英语国家(地区)使用,因为C语言最初假定字符都是单字节的。但是这些假定并不是在世界的任何地方都适用。
后来为了使C语言适应国际化,C语言的标准中不断加入了国际化的支持。比如:加入了宽字符的类型wchar_t
和宽字符的输入和输出函数,加入了<locale.h>
头文件,其中提供了允许程序员针对特定地区(通常是国家或者说某种特定语言的地理区域)调整程序行为的函数。
<locale.h>
本地化<locale.h>
提供的函数用于控制C标准库中对于不同的地区会产生不一样行为的部分。
在标准中,依赖地区的部分有以下几项:
数字量的格式
货币量的格式
字符集
日期和时间的表示形式
通过修改地区,程序可以改变它的行为来适应世界的不同区域。但地区的改变可能会影响库的许多部分,其中一部分可能是我们不希望修改的。所以C语言支持针对不同的类项进行修改,下面的一个宏指定一个类项:
LC_COLLATE
:影响字符串比较函数 strcoll()
和 strxfrm()
。
LC_CTYPE
:影响字符处理函数的行为。
LC_MONETARY
:影响货币格式。
LC_NUMERIC
:影响 printf()
的数字格式。
LC_TIME
:影响时间格式 strftime()
和wcsftime()
。
LC_ALL
:针对所有类项修改,将以上所有类别设置为给定的语言环境。
微软开发文档对类项的介绍
char*setlocale(int category,const char* locale);
setlocale
函数用于修改当前地区,可以针对一个类项修改,也可以针对所有类项。
setlocale 的第一个参数可以是前面说明的类项中的一个,那么每次只会影响一个类项,
如果第一个参数是LC_ALL
,就会影响所有的类项。
C标准给第二个参数仅定义了2种可能取值:"C"
(正常模式)和""
(空字符串,本地模式)。
在任意程序执行开始,都会隐藏式执行调用:
setlocale(LC ALL,"C");
当地区设置为"C"
时,设置为C语言默认的模式,这时库函数按正常方式执行。
当程序运行起来后如果想改变地区,就需要调用setlocale
函数。用""
作为第2个参数,调用setlocale
函数就可以切换到本地模式,这种模式下程序会适应本地环境。
比如:切换到我们的本地模式(汉字是宽字符)后就支持宽字符的输出等。
setlocale(LC_ALL,"");//切换到本地环境
setlocale
的返回值是一个字符串指针,表示已经设置好的格式。如果调用失败,则返回空指针NULL
。
setlocale
也可以用来查询当前地区,第二个参数设为NULL
就可以了。
#include <locale.h>
#include<stdio.h>
int main()
{
char* loc;
loc = setlocale(LC_ALL, NULL);
printf("默认的本地信息:%s\n", loc);
loc = setlocale(LC_ALL, "");
printf("设置后的本地信息: %s\n", loc);
return 0;
}
那如果想在屏幕上打印宽字符,怎么打印呢?
宽字符的字面量必须加上前缀L,否则C语言会把字面量当作窄字符类型处理。
前缀L在单引号前面,表示宽字符,宽字符的打印使用 wprintf
,对应 wprintf()
的占位符为 %lc
。
前缀L在双引号前面,表示宽字符串,对应 wprintf()
的占位符为 %ls
。
#include <stdio.h>
#include<locale.h>
int main() {
setlocale(LC_ALL, "");
wchar_t ch1 = L'●';
wchar_t ch2 = L'微'; //汉字也是宽字符
wchar_t ch3 = L'软';
wchar_t ch4 = L'★';
printf("ab\n");
wprintf(L"%lc\n", ch1); //不要忘记带L
wprintf(L"%lc\n", ch2);
wprintf(L"%lc\n", ch3);
wprintf(L"%lc\n", ch4);
return 0;
}
从输出的结果来看,我们发现一个普通字符占一个字符的位置,但是打印一个汉字字符或者宽字符,占用2个字符的位置,那么我们如果要在贪吃蛇中使用宽字符,就得计算好坐标,让X坐标一直为偶数,不然会出现一些问题。
我们以实现一个棋盘27行,58列的棋盘分析,再围绕地图画出墙,
如下:
初始化状态:假设蛇的长度是5,蛇身的每个节点是●,在固定的一个坐标处,比如(24,5)
处开始出现蛇,连续5个节点。
注意:蛇的每个节点的x坐标必须是2个倍数,否则可能会出现蛇的一个节点有一半儿出现在墙体中,另外一般在墙外的现象,坐标不好对齐。
关于食物,就是在墙体内随机生成一个坐标(x坐标必须是2的倍数),坐标不能和蛇的身体重合,然后打印★。
在游戏运行的过程中,蛇每次吃一个食物,蛇的身体就会变长一节,那么使用链表存储蛇的信息就比较方便了,蛇的每一节其实就是链表的每个节点。每个节点只要记录好蛇身节点在地图上的坐标就行所以蛇节点结构如下:
typedef struct SnakeNode
{
int x;
int y;
struct SnakeNode* next;
}SnakeNode,* pSnakeNode;
要管理整条贪吃蛇,我们再封装一个Snake
的结构来维护整条贪吃蛇:
那么Snake
中应该有哪些数据呢?
另外可以发现,方向只有四个,可以一一列举出来,所以我们可以使用枚举
enum DERCTION //方向
{
UP = 1,
DOWN,
LEFT,
RIGHT
};
状态实际上也是有限的:正常,撞墙,撞到自己,玩家自行退出,也可以一一列举:
enum STATUS
{
NORMAL,
KILL_BY_WALL,
KILL_BY_SELF,
ESC
};
那么Snake
结构体就可以写成这样,在变量名称前加上_
方便与外部变量区分
typedef struct Snake
{
pSnakeNode _Head; //头
enum DERCTION _Dir; //方向
enum STATUS _Sta; //状态
pSnakeNode _Food; //食物
int _FoodAdd; //食物加的分数
int _Score; //当前分数
int _SleepTime; //睡眠时间
}Snake,*pSnake;
那么至此,前期准备基本完成,接下来我们开始完成游戏的核心逻辑。
程序开始就设置程序支持本地模式,然后进入游戏的主逻辑。
主逻辑分为3个过程
游戏开始(GameStart)完成游戏的初始化
游戏运行(GameRun)完成游戏运行逻辑的实现
游戏结束(GameOver)完成游戏结束的工作
注意:setlocale(LC_ALL, "");
不需要放在上面的逻辑中,因为上面的逻辑会随着游戏的再来一把反复执行,而这个代码并不需要反复运行。
<test.h>
#include"game.h"
void game()
{
char input = 'y'; //用于判断是否再来一把
do
{
Snake s = { 0 }; //做出一条蛇,将其中的内容都置为空
srand((unsigned int)time(NULL)); //食物的生成需要随机数,我们在这里设置一下
//开始游戏
GameStart(&s);
//进行游戏
GameRun(&s);
//结束游戏
GameOver(&s);
//这个代码用于解决一个bug,在后面介绍
//这是AI给出的解决办法,就不多介绍了,<conio.h>是这两个函数需要的头文件
//这个while循环是用来读取蛇运行的时候按下的VK虚拟键的循环,
//把在蛇运行的时候按下的VK键的键值全面读走(包括上键的键值)
while (_kbhit()) //_kbhit()检测是否有按键被按下
{
//使用 _getch() 获取按下的键
_getch();
}
//如果是主动退出的,就不需要询问是否再来一把了
if (s._Sta == ESC)
{
input = 'n';
getchar(); //这个getchar用于在release版本下阻止程序直接退出
}
//在结束之后,询问是否要再来一把
else
{
SetPos(15, 15);
printf("要再来一把吗?(Y/y)");
input = getchar();
while (getchar() != '\n'); //清理'\n'
}
} while (input=='y'||input=='Y');
SetPos(10, 27); //程序退出时,会有一个xxx程序已正常退出的提示,我们让它不要破坏游戏地图
}
int main()
{
setlocale(LC_ALL, "");//设置能输出长字符
game();
}
在游戏过程中我们会用到非常多次SetPos
来设置光标位置,至于这些位置的具体坐标可以自行不断尝试来找到较好的位置,博客中的是我个人觉得比较好的。
这个部分要完成的任务:
控制台窗口大小的设置
控制台窗口名字的设置
鼠标光标的隐藏
打印欢迎界面
创建地图
初始化蛇
创建第一个食物
我们将其中的每一个任务分别封装成一个函数:
void GameStart(pSnake ps)
{
//设置控制台大小,隐藏光标
SetInit();
//打印欢迎界面
Welcome();
//布置地图
InitMap();
//打印介绍信息
InfoPrint();
//放蛇
SnakeInit(ps);
//放食物
CreatFood(ps);
//getchar(); //可以用来停止代码执行,方便调试,项目完成后要注释掉
}
void SetInit()
{
system("mode con cols=100 lines=30");
system("title 贪吃蛇");
HANDLE Houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO stdoutinfo;
GetConsoleCursorInfo(Houtput, &stdoutinfo);
stdoutinfo.bVisible = false;
SetConsoleCursorInfo(Houtput, &stdoutinfo);
}
这个函数就是上一篇文章的主要内容,这里就不再赘述了。
void Welcome()
{
//打印欢迎界面
SetPos(40, 15);
wprintf(L"欢迎来到贪吃蛇");
SetPos(40, 25);
system("pause"); //这个代码相当于打印一个"请按任意键继续...",和 getchar();
system("cls"); //清空屏幕
SetPos(25, 12);
wprintf(L"按↑↓←→控制方向,F1加速,F2减速,速度越快,分数越高"); //打印汉字也可以使用 printf
SetPos(25, 13);
wprintf(L"空格键暂停,ESC退出");
SetPos(40, 25);
system("pause");
system("cls");
}
这个函数就是游戏最开始的两个界面。
我们在这个函数中会用许多次宽字符,为了方便使用,我们可以在头文件中进行宏定义:
#define WALL L'□'
#define SNAKE_BODY L'●'
#define FOOD L'★'
这样,比如我们要打印墙体,我们就可以直接:
wprintf(L"%lc",WALL);
参考代码:
void InitMap()
{
for (int i = 0; i < 60; i += 2)
wprintf(L"%lc",WALL); //打印第一行
SetPos(0, 28 - 1);
for (int i = 0; i < 60; i += 2)
wprintf(L"%lc", WALL); //打印最下面一行
for (int i = 1; i < 28; i++)
{
SetPos(0, i);
wprintf(L"%lc", WALL); //打印左边一列
}
for (int i = 1; i < 28; i++)
{
SetPos(60 - 2, i);
wprintf(L"%lc", WALL); //打印右边一列
}
}
可以在这里打印上自己的名字做个防伪认证:)
void InfoPrint()
{
//打印提示信息
SetPos(64, 15);
printf("不能穿墙,不能咬到自己\n");
SetPos(64, 16);
printf("用 ↑. ↓. ←. → 分别控制蛇的移动.");
SetPos(64, 17);
printf("F1 为加速,F2 为减速\n");
SetPos(64, 18);
printf("ESC :退出游戏.space:暂停游戏.");
SetPos(64, 20);
printf("CSDN:fhvyxyci");
}
分数和每次吃食物的得分由于要刷新,就不在初始化的时候打印了。
void SnakeInit(pSnake ps);
初始化蛇的步骤:
void SnakeInit(pSnake ps)
{
pSnakeNode cur = NULL;
for (int i = 0; i < 5; i++) //初始设置长度为5
{
//创建一个节点,这一步也封装成 BuyNode
pSnakeNode cur = (pSnakeNode)malloc(sizeof(SnakeNode));
if (!cur)
{
perror("SnakeInit()::malloc()");
exit(1);
}
cur->x = X_INIT + 2 * i; //X_INIT和Y_INIT是宏定义,方便修改初始坐标
cur->y = Y_INIT;
cur->next = NULL;
//头插
if (!ps->_Head)
{
ps->_Head = cur;
}
else
{
cur->next = ps->_Head;
ps->_Head = cur;
}
}
//打印蛇
cur = ps->_Head;
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", SNAKE_BODY);
cur = cur->next;
}
//初始化数据
ps->_Dir = RIGHT; //初始方向为右
ps->_FoodAdd = 10; //初始食物的分数为10
ps->_Score = 0; //初始分数
ps->_SleepTime = 200; //_SleppTime与速度有关
ps->_Sta = NORMAL; //初始状态是正常
}
这个函数不是只在初始化的时候调用,写的时候可能要注意一下。
void CreatFood(pSnake ps)
{
int x = 0, y = 0;
again:
do
{
x = rand() % 55 + 2; //注意范围
y = rand() % 26 + 1;
} while (x % 2 == 1); //x必须是偶数
pSnakeNode cur = ps->_Head;
while (cur)
{
//检查食物是否与身体重合
if (cur->x == x && cur->y == y)
goto again; //当然,goto语句一般不推荐使用,你可以改造一下这里的逻辑,换成循环
cur = cur->next;
}
//食物要有x,y坐标,那不如直接把它做成一个SnakeNode,这样还可以方便后面吃食物
pSnakeNode food = (pSnakeNode)malloc(sizeof(SnakeNode));
if(!food)
{
perror("CreatFood()::malloc()");
exit(1);
}
food->x = x;
food->y = y;
food->next = NULL;
ps->_Food = food;
//打印食物
SetPos(ps->_Food->x, ps->_Food->y);
wprintf(L"%c", FOOD);
}
剩下的逻辑在后面的博客中介绍。
谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!
我会持续更新更多优质文章