前言:本文所写的贪吃蛇是笔者初学QT练手的小项目,做出来的界面较为粗糙。由于很久没有接触C++,程序中类封装的不是很规范。写这篇文章,权当是记录生活了,手动狗头。还有就是写的比较啰嗦,建议跳跃性阅读。文章末尾附代码文件下载链接。
概述:一张16格x16格地图,初始长度为2格,宽度为1格的Snake,地图中随机合法位置刷新食物;
游戏规则:
玩家使用上下左右(或者WSAD)控制Snake的前进方向;
Snake成功吃到食物后长度加1;
吃到自己身体或撞到地图边界死亡;
关于Size:地图为16格x16格,实际编程窗口中取:1格=30x30像素,即地图尺寸为480x480像素
关于地图:使用QT绘制组件QPainter绘制480x480的浅灰色填充矩形
定义MyWin类(继承QWidget基类),用于创建窗口以及按钮、标签、画布等子控件,包含以下变量及成员:
- MyWin(),默认构造函数
- ~Mywin(),默认析构函数
- void paintEvent(QPaintEvent*),绘图事件重写
- void keyPressEvent(QKeyEvent* event),键盘监听事件重写
- QTimer* timer,定时器,核心部件,可以定时发出信号,完成程序界面刷新动作
- Snake* m_snake,生成Snake类对象,即初始化一条蛇,根据对象的信息在地图中进行绘制
- QLabel*,QPushButton*等子控件,用于界面功能设计
定义Snake类,用于生成一条蛇,包含以下变量及成员:
Vector
snake_node变量,存储Snake身体结点;例如:[[2,2],[2,3],[3,3]…] char direction变量,Snake当前方向,‘U’==Up,‘D’=Down,‘L’=Left,‘R’=Right
int head_x,head_y,蛇头坐标
int score,游戏得分,成功吃下一个食物,得分加 1
int snake_length,蛇身长度
moveSnake()函数,判断移动是否合法,并相应改变Snake的身体结点坐标,
Vector mapFlag(256,1),定义一个二维数组mapFlag,大小为16x16,与地图相对应。遍历数组,将Snake身体所处的结点标为 0,当蛇头移动至被标记为 0 的结点,即吃到自己身体,游戏结束。
同时,将食物结点标为2,当蛇头移动至标为 2 的结点时,即吃到食物。
定义Food类,用于生成食物,包含以下变量及成员:
编写Food类,写到一半发现,Food类中变量成员太少了,单独开.h .cpp文件简直是可耻的浪费,于是干脆把变量成员都塞到Snake类里了,不建议这样写,因为不利于阅读以及后期维护(我就默默偷懒了)
- int food_x,food_y,食物坐标
- void newFood(),生成新的食物
蛇的绘制:
蛇身体的绘制,Snake类中变量vector
要想绘制结点[0,1]即图中绿色部分,首先需要获得绿色区域左上角的坐标,根据映射关系可以得到,左上角横坐标 = 80 + 30 * 0;纵坐标 = 60 + 30 * 1;(80,60分别是顶级窗口左上角到地图左上角的横纵距离,计算时要代入),所以绿色区域左上角的像素坐标为(80, 90),使用以下语句即可填充此区域:
painterSnakeBody.drawRoundedRect(80, 90, single_size, single_size, 10, 10)
painterSnakeBody是绘画控件对象;drawRoundedRect是画圆角矩形函数;80,90为矩形左上角坐标;single_size为30,是每1格的像素长度;最后两个参数10表示矩形圆角的弯曲程度。
遍历snake_node数组,并按上述步骤操作,即可画出蛇的所有身体。
蛇头的绘制,绘制步骤相同,但蛇身结点使用黑色绘制,蛇头使用红色绘制
蛇眼睛的绘制,眼睛的绘制区域与蛇头区域相同,不同的是眼睛的部位需要视情况而定,将头结点那格区域(30x30像素)放大如下:
如图所示:1,2,3,4分别为眼睛可以放置的位置,当蛇向上移动时,即蛇头向上,眼睛的绘制区域应该为1,2;蛇头向右时,眼睛绘制区域为2,4;蛇头向下时,眼睛绘制区域为3,4;蛇头向左时,眼睛绘制区域为1,3;根据不同情况,在相应位置绘制直径为3的白色圆形,具体坐标的映射计算就不再赘述,代码如下:
painterSnakeEye.drawEllipse(site_x, site_y, 6, 6);
食物的绘制:
创建定时器对象:
定时器QTimer介绍:QT中的一个控件,初始化时调用timer->start( T )函数,T可以传入单位为毫秒的参数,比如1000,则定时器timer的功能就是每1000ms发出一个超时信号,不断循环这个操作,直到程序退出或对象调用stop()函数(笔者对定时器了解不深,这段概括可能不够严谨,具体事项请阅读QT官方说明文档)
QTimer* timer = new QTimer(this);
设置超时周期:
timer->start(1000);
信号与槽
将每次定时器发出的超时信号作为信号函数,以此触发槽函数。在槽函数中,即新的周期开始时,我们需要更新绘图事件,按照新周期当前snake_node结点坐标和食物坐标重新绘制蛇与食物,这样便可以达到蛇在移动的视觉效果;我们还需要更新界面上游戏得分标签子控件。
(void)connect(timer, &QTimer::timeout, [=](){
update();
label_score_2->setText(QString::number(m_snake->score));
});
上述代码中,connect()函数功能是将信号与槽连接起来,在QT早期版本,connect不支持Lambda表达式的使用,所以传入参数形式如:connect(信号发出者,信号,信号接收者,槽);在新版QT中,加入了Lambda表达式的使用,所以connect的传入参数中可以直接编写槽函数,并且可以省略信号接收者参数。针对上述代码,我们逐句分析理解:
Snake *m_snake = new Snake
;score是当前对象拥有的属性,即当前游戏得分;所以这段代码的意思就是:定时器发出超时信号,执行绘图事件,更新地图,游戏得分标签更新。
蛇的移动
仅仅是每周期执行绘图事件,刷新地图是没有办法让蛇移动起来的。要知道,之前定义的snake_node数组中存放着蛇的全部结点坐标,绘图事件是根据snake_node数组来绘制蛇的,因此只有让snake_node数组发生变化并每周期绘制一次才能达到蛇移动的效果,因此可以理解移动蛇实际上是对snake_node数组进行操作。状态A移动至状态B后snake_node数组的变化,如下图所示:
每次蛇的移动都可以看作为,snake_node数组删除第一对坐标[0,1],并添加新坐标[2,1],代码如下:
snake_node.erase(snake_node.begin());
snake_node.push_back([2,1]);
.erase()函数功能即删除数组成员,.begin()是返回数组第一位成员的迭代器,组合起来便是删除第一队结点坐标[0,1];
.push_bakc()是向数组最后添加一位成员;实际上push_back()不支持上述表达式中的语法,这样写是为了更加容易理解,编程时需要先定义一个一维数组temp_node,将2,1塞进一维数组后,再执行push_back(temp_node)将[2,1]添加进去,代码如下:
vector temp_node = { 2,1 };
snake_node.push_back(temp_node);
加入键盘监听事件
为了实现程序运行过程中能够实时循环接收键盘输入的内容,我们重写QT键盘监听事件,先在窗口类MyWin中声明,再在类外实现,键盘事件实现代码如下:
void MyWin::keyPressEvent(QKeyEvent* event)
{
switch (event->key())
{
// 键盘的方向键'上'或大小写'w/W'
case Qt::Key_Up:
case 'w':
case 'W':
if (m_snake->direction == 'D') break;
m_snake->direction = 'U';
break;
// 键盘的方向键'下'或大小写's/S'
case Qt::Key_Down:
case 's':
case 'S':
if (m_snake->direction == 'U') break;
m_snake->direction = 'D';
break;
// ······省略左右操作
default:
break;
}
}
上述代码中,void MyWin::keyPressEvent(QKeyEvent* event)
即键盘事件函数的实现,注意在keyPressEvent前要加上作用域MyWin;
event->key()即键盘监听事件程序从键盘获取的内容,比如我们按下键盘上的字母’T’,那么event->key()的值就是’T’(至于函数这个实时不断获取功能是怎样实现的,笔者也是比较好奇,有机会可以学习一下)。
综上,当我们在键盘上按下了字母’W’,即希望蛇向上移动,代码进入Switch第一段分支语句,这里我们需要做个判断,如果当前蛇的方向是向下的(Down),那么我们执行向上操作显然是非法的(总不能让蛇原地转头吧),因此操作非法时,直接break,退出这一次键盘监听程序,忽略本次操作;若合法,将对象m_snake的方向赋值为上’U’(Up),然后退出这一次键盘监听程序。m_snake->direction
即对象m_snake拥有的属性direction,direction的值即当前蛇的朝向。
我们总共需要4段分支语句,分别判断输入的上下左右,这里代码比较长,且逻辑重复性高,就不贴出来了,只贴了上下判断的分支语句。
控制蛇的移动
现在我们已经可以接收键盘输入的内容,要做的就是编写移动函数moveSnake(),实现根据键盘输入的内容而为snake_node数组添加相应的结点,函数实现如下:
int Snake::moveSnake()
{
// 根据方向移动蛇头
if (direction == 'U') head_y--;
else if (direction == 'D') head_y++;
else if (direction == 'L') head_x--;
else head_x++;
// 若移动不合法,吃到自己或撞墙
int head_site = 16 * head_y + head_x;
if(head_x < 0 || head_x>15 || head_y < 0 || head_y>15 || mapFlag[head_site]==0) {
return 0;
}
// 将蛇尾对应标志地图置为1,蛇头置为0
int temp_site = snake_node[0][1] * 16 + snake_node[0][0];
mapFlag[temp_site] = 1;
mapFlag[head_site] = 0;
// 将蛇头加入数组,并删除蛇尾
vector temp_node = { head_x,head_y };
snake_node.push_back(temp_node);
snake_node.erase(snake_node.begin());
return 1;
}
上述代码第一段,根据当前蛇的朝向来改变头结点的坐标,示例如下:
若当前头结点坐标为[3, 3],且蛇当前朝向上边,所以下一周期头结点的位置即为[3, 3 - 1];
若当前头结点坐标为[3, 3],且蛇当前朝向下边,所以下一周期头结点的位置即为[3, 3 + 1];
以此类推,可以得到蛇头下一周期的坐标。
代码第二段,如果第一段计算出的坐标超出了16x16,或者在标志地图上对应的标志为0(表示该坐标处有蛇的身体),就说明蛇撞到了墙或者吃到了自己,直接返回0,表示蛇已经死亡。
代码第三段,将蛇尾的区域对应的标志地图赋值为1,表明该区域已经没有蛇身体了,已经是空白区域了;再将蛇头即将进入的区域赋值为0,表示蛇已经进入该区域了;
代码第四段,即snake_node数组操作,删除第一位,数组末尾添加一位,并返回1,表示移动成功;
食物的生成相对较于简单,只需要注意几点:1)食物的生成应当是随机的;2)一次只能生成一颗食物,在已有食物被吃掉之前不能触发生成函数;3)食物需要生成在地图中合法位置,不允许生成在蛇的身体上。
随机生成机制
一开始的想法很简单,地图是16格x16格,所以先在[0, 15]区间内取个随机数当作 X,再次取个数当作 Y,这样不就有了一个随机坐标,当作食物坐标就行了。如果随机取的坐标不合法,就舍去重新再随机一个。写到一半突然想起来,这样的效率太低了,到最后蛇的身体快占满地图的时候,这时想要随机取出一个合法的坐标,概率太低了。所以想了想换成了下面的生成机制。
地图16x16总共256格合法位置可以生成食物,当地图中有了蛇的身体,合法区域会随之减少,例如现在蛇的长度为50格长,那么还剩下206格合法位置可以刷新食物,因此在[0, 205]区间内随机取值,无论随机数是多少,必然得到的都是一块合法的区域
食物生成代码
void Snake::newFood()
{
// 食物随机刷新机制如下:
// 标志地图为256大小的数组,地图中空白区域对应数组中的标志为0,蛇身区域为1,食物为2
// 每次将数组大小256减去蛇身长度,得到余留空白区域的大小,以此大小为范围取随机数
// 例如蛇身长度为250,说明余留空白区域大小为6,随机数只需在[0,5]范围内取值即可
// 例如随机数取值为4,遍历标志地图数组,找到第4个空白区域,并刷新食物
srand((unsigned)time(NULL));
int site_food = rand() % (256 - snake_length);
int i = 0;
for (; i < 256; i++) {
if (mapFlag[i] == 1) {
site_food--;
if (site_food == -1) break;
}
}
food_x = i % 16;
food_y = i / 16;
mapFlag[i] = 2;
}
找到合法位置后,将该位置对应的标志地图赋值为2,表示该位置是食物;并对食物的坐标进行修改。
吃掉食物
与移动到空白区域不同,蛇头移动到食物区域时,对蛇的moveSnake()函数要进行部分修改。当蛇吃到食物时,进行以下操作:
在moveSnake()函数中增加以下代码:
// 若遇见食物,进行吃食物操作
if (mapFlag[head_site] == 2) {
mapFlag[head_site] = 0;
vector temp_node = { head_x,head_y };
snake_node.push_back(temp_node);
snake_length++;
score++;
return 2;
}
返回值为 2,表示这次移动吃到了食物,告诉对象m_snake调用生成新食物函数newFood();
至此,贪吃蛇简单的核心功能已经全部完成,剩下的就是些修修补补的工作。
实现贪吃蛇速度调节功能,其实很简单,之前我们定义的定时器使用函数timer->start(T)时,传入的参数是T=1000,即1000ms刷新一次,如果我们将T改为500,即1s内刷新两次,也就是蛇1s内移动两格,速度就是之前的两倍。因此要想实现多级速度调节,我们只需要将 ( T ) 改为 ( 1000 / game_speed ),game_speed数据类型为整形,取值为[ 1, 2, 4 ,8 ],因此现在传入的参数可以是[ 1000, 500, 250, 125 ],对应着四种速度。
再设计两个按钮,分别对应着 ’ - ’ 和 ’ + ’ ;按下减速按钮时,将game_speed的值翻倍,并重新调用start()函数,当按下加速按钮时,将game_speed的值除2,并调用start()函数;对应减速按钮的点击事件代码如下:
// 按钮事件:减速按钮
(void)connect(button_speed_sub, &QPushButton::clicked, [=]() {
if (game_speed > 1)
{
game_speed = game_speed / 2;
// 显示当前速度的标签
label_cur_speed_2->setText("x" + QString::number(game_speed));
timer->start(1000 / game_speed);
}
});
我这里设置的game_speed的最小值为1,最大值为8,所以会有上下限的判断;当然也有其他很多思路,比如给T设定一个初始值,每次按下加速按钮时,T就加上200;每次按下加速按钮时,T就减去200(注意判断小于0);
相同的,实现暂停游戏也很简单。用到定时器中的一个函数timer->stop(),即定时器停止函数;
这边的逻辑部分需要注意一下,当按下”暂停游戏“按钮时,游戏暂停,并且”暂停游戏“字样应当改为”继续游戏“;相反,当按钮”继续游戏“时,字样也应当发生改变;要实现单个按钮状态的切换,我在网上没有找到好的方法,智能曲线救国,在程序中加了个标志位:
// 0:表示游戏正在运行 1: 表示游戏正在暂停
int pause_flag;
因此,暂停按钮的点击事件部分可以这样写:
// 游戏暂停按钮响应事件
(void)connect(button_game_pause, &QPushButton::clicked, [=]() {
// 根据暂停标志得知当前游戏状态
// 从而切换该按钮的状态
switch (pause_flag)
{
case 0:
timer->stop();
pause_flag = 1;
button_game_pause->setText(QString::fromLocal8Bit("继续游戏"));
break;
case 1:
timer->start(1000 / game_speed);
pause_flag = 0;
button_game_pause->setText(QString::fromLocal8Bit("暂停游戏"));
break;
default:
break;
}
});
在原窗口实现重新开始功能的思路如下:
m_snake = new Snake;
实现代码如下:
// 重新开始按钮响应事件
(void)connect(button_restart, &QPushButton::clicked, [=]() {
// 重新实例化
m_snake = new Snake;
game_speed = 1;
pause_flag = 0;
// 速度标签重置
label_cur_speed_2->setText("x" + QString::number(game_speed));
// 得分标签重置
label_score_2->setText(QString::number(m_snake->score));
// 暂停标志重置
pause_flag = 0;
// 将暂停按钮重置
button_game_pause->setText(QString::fromLocal8Bit("暂停游戏"));
// 重新绘制
update();
// 启用定时器
timer->start(1000 / game_speed);
});
不知道大家有没有看清楚蛇是怎么死的,它原地掉头把自己咬到了(可以看到蛇死的时候,眼睛是向左边的)。当时出现这个bug的时候很懵逼,明明写了程序判断,当蛇朝向右边时,向左移动是非法操作,是不执行任何操作的,那为什么会出现这个问题呢?
其实,在那个周期内,我迅速的按了 ’ W ’ ,’ A ’ 两个键,即先向上再向左;这时程序先判断,发现向上移动合法,于是将蛇的方向改为了向上 ’ U ',还没等到下一周期,蛇头还未发生移动(蛇每周期移动一格),这时程序又接收到了向左的操作,因为当前方向已经改成了向上,所以此时向左也是合法操作,于是又将蛇的朝向改为了向左,等于在一周期内发生了两次转向操作,并且都是合法的,于是到了下一周期,执行moveSnake函数的时候,蛇头直接咬到了自己。
问题的缘由弄清楚,那么怎么避免单周期内多次操作呢?
我的做法是:为每个周期加个标志,即定义
int step_count = 1
;比如定时器第一次循环的时候,step_count的值为 1,可以认为当前周期叫做 ’ 1 ’ 周期,第二次循环的时候,step_count的值加 1,所以第二周期可以叫做 ’ 2 ’ 周期,以此类推,每周期开始时将step_count的值加 1,表示为该周期的标识;再添加一个标志位step_key_input,当键盘输入时,将当前周期的标识赋值给step_key_input,下次键盘输入时,判断step_key_input是否等于step_count,如果等于说明在这个周期内,已经有过一个合法输入了,本次操作不执行。
举个例子:就类似上面出错的情景,在定时器循环的第3个周期,step_count = 3,蛇朝向左边,这时我们输入向上操作,于是step_key_input = 3,当前朝向被修改为向上;紧接着,我们又输入了向左操作,由于此时的step_count = step_key_input = 3,所以本次向左操作被视为非法操作,故不执行。在当前周期内,只允许1个合法操作,这样就可以避免上述bug的发生。
关于这个问题,肯定有很多不同的解决思路,上述方法只能说是解决问题,算不上是最优方案,还是要多思考,多学习。最后附一张菜鸡截图:
附代码文件:
笔者环境是VS2019社区版+QT5(注意:QT4可能不兼容,特别是connecct函数那部分),与在QT中直接编译可能会有些不同,代码应该是没有问题的;建议无论是使用VS还是QT,先创建QT界面项目,然后用下面的文件将项目中的替换掉,否则可能无法运行。
C++/QT 贪吃蛇简陋小游戏
百度网盘链接(提取码:aciq)