从贪吃蛇开始,面向初学者的简单程序设计教程(C++版)
0. 前言
因为个人的一些原因,周围有很多想要转行做程序的小伙伴,他们都苦于如何快速地学会一门语言并应用于实际项目中。而我当年初学程序时受《Qt学习之路2》的启发,从贪吃蛇这个小型程序中学到了很多书本上没有的知识,有种醍醐灌顶的感觉。现特地编写此博客来帮助我的小伙们,本文将会分成C++和Java两个版本分别基于Qt和Java Swing来介绍一种面向对象的程序设计思路,以及遇到问题的解决方法。
此文为C++版本(Java版本仍在编写中)
在阅读本文前你需要…
- 学会安装和配置编程所需要的环境,如gcc/jdk之类的编译器和Qt Creator/Intellij IDEA之类的集成开发环境。
- 熟悉C++/Java最基础的知识,至少要对书上前几章的基础知识有所印象
- 学会百度。这个看似很简单,但是很多小伙伴很难在网上找到自己想要的答案。而且有的文章比较简略,有的文章比较深入,很难找到适合自己的文章。本文暂不作说明。
为什么是贪吃蛇程序
- 贪吃蛇代码量较小,大概一共只有200~300多行。
- 贪吃蛇程序涉及到众多的基础知识,覆盖面广,可以充分地让人理解到为什么要使用一些语法和函数,而不仅仅只停留在课后习题上。
- 贪吃蛇程序可以让你更直观地感受到如何将人脑中的思维和方案转换成程序代码
PS:本文仅为个人理解和整理,如有错误与不足还敬请大佬指出,谢谢。
1. 从主函数开始的贪吃蛇之旅
新建一个项目
本文主要介绍在Win10系统下以Qt Creator 4.14.2来编写程序。熟悉的小伙伴可以直接跳过,下面直接简述流程:
安装和配置好Qt以后,点击“文件”->“新建文件或项目…”
左侧选择“Application(Qt)”,中间选择"Qt Widgets Application"
选择自己喜欢的工程名称和工程位置
在Details这里重命名Class name为View,Base class选择QWidget
在Kits这里选择配置好的qt版本
其他选择默认配置即可,最后可以看到如下界面:
main函数内容简析
一般来说,程序的运行都是从main开始到main结束,很多控制台程序都是一次性打印完数据就结束了。但是如果运行的是Qt窗口,会等到用户点击关闭之后才会结束程序。其中主要的奥秘就在于这两句:
QApplication a(argc, argv);
return a.exec();
QApplication的exec()方法可以看作一个死循环,这个循环将main函数阻塞住程序就不会自动结束了。然而当Qt检测到最后一个窗口关闭时,Qt就会退出程序,即从循环中break出来。这样,窗口就会可持续的存在于桌面上了。
view则是Qt程序的其中一个窗口,调用show()方法显示出来。这个窗口之后会成为显示游戏内容的主要界面。
2. 绘制场景
什么是MVC框架
这个贪吃蛇将会使用MVC框架来进行类的划分。M指模型(Model),这一部分主要管理数据的存储方式,排序或查找的方;V指视图(View),这一部分主要管理界面和其他图形的绘制,先前创建的View类则代指这一部分;C指控制器(Controller),这一部分主要管理用户如何与程序交互,如何协调视图和模型的变化。
MVC是一种非常常见的设计模式,其本质是一种分类思想。用代码也可表示为Model类,View类,Controller类。当然,不是说所有的程序都一定要用MVC框架,毕竟分类只是人主观的想法,用其他方法分类仍然可以解决问题。但是一个好的设计方法可以让程序更容易理解,且不容易出bug。
首先从视图开始吧
在创建Qt工程时已经创建了一个QWidget类,这个类不仅是用于显示窗口的类,将其设计出来主要是为了管理所有的视图功能。
如果直接运行程序,可以看到程序会弹出一个名为View的空白窗口。想要修改窗口的内容,首先可以考虑调用QWidget类的函数,即Qt提供的接口。
如何使用接口
虽然我们都知道有万能的自动补全大法,但在Qt Creator中还是推荐使用文档。如果你英文不是很好的话网上还有中文文档(https://www.qtdoc.cn/),但我个人还是强烈推荐使用内嵌的英文文档。
将鼠标放在IDE高亮的QWidget上,按下F1,右侧会出现QWidget类说明文档,点击Public Functions,可以看到QWidget推荐我们使用的很多函数。点击函数名称,就可以看到更加具体的说明了。
设置窗口属性
QWidget下有很多"set"开头的函数,包括设置窗口大小,标题,图标等。为了之后方便和美观,请至少设置一下窗口的标题(Title)和固定大小(FixedSize)。一般来说,界面的初始化需要放在构造函数当中:
//view.cpp
#include "view.h"
#include "ui_view.h"
//构造函数
View::View(QWidget *parent)
: QWidget(parent)
, ui(new Ui::View)
{
ui->setupUi(this);
setFixedSize(800,600);//设置窗口大小为800x600(像素)
setWindowTitle("贪吃蛇");//设置窗口标题
}
//析构函数
View::~View()
{
delete ui;
}
在作出更改后,最好可以及时地测试程序。点击"▷"运行以后可以看到窗口发生了变化。
绘制背景
为了使程序看起来更有点感觉,下面可以先把游戏的网格状地图绘制出来。Qt给了我们一个可以自定义绘图的接口void paintEvent(QPaintEvent*)
但是这个接口并不是调用一下就能够使用了。如果查找文档的话,会发现它被归类为Protected Functions里。这代表开发者需要重写这个函数才能使用它。重写这个函数的特点之一就是:开发者可以将自定义的代码和逻辑放入其中,而不像普通函数只能传递变量。
然后根据C++类的结构分别在头文件和源文件中添加:
//view.h
#include <QPaintEvent>
protected:
void paintEvent(QPaintEvent*);
//view.cpp
void View::paintEvent(QPaintEvent *event)
{
//自定义代码写在这里
}
然后,为了能在Qt界面中绘制图形,需要用到Qt的QPainter类作为画笔,在QPainter类下有很多draw开头的绘图方法,包括绘制矩形、椭圆、多边形等。这里主要填充背景颜色和绘制网格:
//view.cpp
#include <QPainter>
const int SIDE_LENGTH = 20;//单元格边长
void View::paintEvent(QPaintEvent *event)
{
QPainter painter(this); //构造函数指定QPainter绘画的目标,this为本窗口
//窗口背景变为白色
painter.fillRect(0,0,width(),height(),QColor(255,255,255));
//设置线条颜色为灰色
painter.setPen(QColor(200,200,200));
//绘制垂直线
for(int i=0; i<width(); i+=SIDE_LENGTH)
painter.drawLine(i,0,i,height());
//绘制水平线
for(int i=0; i<height(); i+=SIDE_LENGTH)
painter.drawLine(0,i,width(),i);
}
写好代码以后可以看到窗口中已经显示出草稿纸一样的背景了:
思路与解析
这些绘图的代码并不是一下子就能写出来的。首先需要了解下Qt的窗口绘图坐标系,以左上角为原点,向右是x轴正方向,向下是y轴正方向(单位:像素)。
之后按照需求,先填充背景颜色,后用循环绘制多条线覆盖在背景之上。
依据需求可以找到QPainter下有fillRect(填充矩形区域)和drawLine(画线)两个函数。这两个函数有很多重载的方法,选择一个方便理解的即可。
fillRect的其中一种重载需要矩形的长宽,于是就需要窗口的大小。可以调用QWidget下的width()和height()函数获得长宽。最后一个参数需要一个颜色,这里定义了个匿名变量QColor,依次指定了RGB的颜色值。
drawLine函数其中一种重载需要线段的起点和终点。绘制水平线时,纵坐标y会随着循环变化,长度固定为width();绘制垂直线时,横坐标x会随着循环变化,而长度固定为height()。
这里会发现最好有个单元格边长,于是可以添加一个通用的常量SIDE_LENGTH来规定单元格的边长
然后发现线段的颜色没有指定,再调用setPen()函数便可以指定颜色了。
什么时候会运行绘图的函数呢
还记得QApplication::exec()吗,QApplication负责了将主函数阻塞住,而在这个循环中Qt就会负责很多其他工作。其中有一个功能便是负责分发来自系统的事件,当系统需要刷新界面时便会通知Qt程序,之后Qt程序会再通知paintEvent()
感兴趣的小伙伴可以在paintEvent()里增加断点,就能在Qt的debug模式里看到调用到的所有函数的栈和变量存储的内容,看看事件是如何一步步传进来的。
3. 建立模型与其他分类
为什么要建立模型
有了场景之后就需要考虑如何绘制一条蛇。然而现在会遇到一个问题,在paintEvent()中,必须要指定画笔的起点、终点、长度、宽度等信息。但是,在场景的绘制中,这些数值是定死的。想要让蛇动起来,必须要实时地改变蛇的位置,那么现在就需要一个模型类,专门负责处理场景上的动态内容。
虽然在编写模型类的时候仍然无法看到界面动起来,但是这正是MVC架构的优点。开发者无需关心其他类是如何实现的,只需要调用几个接口,就可以完成与其他模块的联动。
所以,在编写模型类时,请暂时不用考虑其他问题。其他问题将在之后迎刃而解。
在Qt工程中新建一个Model类,其不用继承任何其他类。新建完成后左侧会出现model.h和model.cpp两个文件。
长期存储动态的游戏对象
首先要考虑如何长期地存储一条蛇和地图上的食物
简单介绍数据结构——链表
链表,顾名思义,是一种像锁链一样层层相扣的数据结构,其内部存储自身的元素内容和下一个锁链环的地址。如果画出来并将其拉长,就很像一条蛇:
这种数据结构很容易能表示一条蛇,而且对之后的代码也会有所帮助。链表的数据结构用代码可以这么表示:
struct Node{
Element *content;
Node *next;
};
不过为了方便,可以直接使用Qt的链表,它不仅实现了这个存储结构,还实现了各种方法:
//model.h
#include <QList>
class Model
{
public:
Model();
private:
QList<T> snake_chain;
}
然后就发现有这么一个问题:这个T是什么?
QList是一种模板类,可以将任何类型填入,即表示它存储的元素都是这一种类型。有些教程里会用整数表示蛇在地图上的模型,虽然这肯定是不会出bug的,但是既然要学习面向对象的方法,就要新建两个类:Snake和Food。并把自定义的Snake类放入链表之中。
加入新的分类
Snake和Food是按照不同的游戏对象进行分类的,不同于MVC的分类标准。为了使其能够与MVC框架对接上,需要分别在里面实现MVC的代码。这里因为不会对单个游戏对象进行操控,所以没有Controller的代码。
先新建一个Snake类,增加需要实现的函数:
//snake.h
#include <QPainter>
class Snake
{
public:
Snake();
void setPos(int x,int y); //设置坐标
int getX(); //获得横坐标
int getY(); //获得纵坐标
void paint(QPainter *painter); //自定义绘制方法
private:
int x; //横坐标
int y; //纵坐标
};
其中坐标系的功能是提供给Model的,而paint方法是提供给View的。这里paint里的参数是因为需要外部的实例,这个外部的实例就是QWidget的画笔。窗口的实例和Snake实例并不是同一个对象,因此可以用函数来传递内部的实例。
然后,可以来编写函数的具体实现了。其中有两点需要注意,一个是坐标系是指画完格子之后的新坐标系,而非窗口像素坐标系;另一个是绘图函数是负责绘制当前这一个方块的函数,而不用绘制所有的方块。
//snake.cpp
#include "snake.h"
//从view那边复制过来的常量
const int SIDE_LENGTH = 20;
Snake::Snake()
{
// 在构造函数里初始化成员变量以防出现bug
x=0;
y=0;
}
void Snake::setPos(int x,int y)
{
// 变量重名时使用this指针,指代成员变量
this->x = x;
this->y = y;
}
int Snake::getX()
{
return x;
}
int Snake::getY()
{
return y;
}
void Snake::paint(QPainter *painter)
{
//注意坐标系的转换
painter->fillRect(x*SIDE_LENGTH,y*SIDE_LENGTH,SIDE_LENGTH,SIDE_LENGTH,Qt::red);
}
如果对坐标系转换理解有困难,可以画个图参考一下
这样有了Snake类以后可以先放入View里测试一下,以下为新增代码:
//view.cpp
#include "snake.h"
void View::paintEvent(QPaintEvent *event)
{
//...省略绘制场景代码
Snake snake1;
snake1.setPos(2,2);
Snake snake2;
snake2.setPos(3,2);
Snake snake3;
snake3.setPos(4,2);
snake1.paint(&painter);
snake2.paint(&painter);
snake3.paint(&painter);
}
点击运行,看下效果。感觉还行。
继承和多态
现在实现了Snake类之后,就要实现Food类了。最简单的方法就是直接把代码复制过去,改个类名,换个绘制图形就行了。
但是呢,这样就会产生冗余的现象,多个类的代码都一样了。如果这是一个大工程,分出来很多类是没有意义的,只会让人看着难受。
所以,现在要设计一个GameObject的父类,将相同的功能提取出来,有点像高中学过的提取公因式。
新建一个GameObject类,将snake类的代码复制过来并修改类名等,使其符合语法规则。因为paint方法会绘制不同的图形来区分蛇跟食物,将paint()变为虚函数,方法体置空。
//gameobject.h
#include <QPainter> //不要忘记
class GameObject{
public:
//...设置坐标的函数
virtual ~GameObject(){}; //出现类的继承,加上虚析构函数防止内存泄露
virtual void paint(QPainter *painter){};//空函数可内联
protected: //修改作用域,以便子类使用
int x;
int y;
}
//gameobject.cpp
//将paint函数内联到头文件
然后新建Food类,让Snake类和Food类都继承GameObject类
并且只需要声明和实现一个paint()方法即可
//snake.h
#include <QPainter>
#include "gameobject.h"
class Snake : public GameObject
{
public:
//其他函数都可以删除了
Snake(){}; //函数体为空可以内联
~Snake(){};
void paint(QPainter *painter);
};
//snake.cpp
#include "snake.h"
const int SIDE_LENGTH = 20;
void Snake::paint(QPainter *painter)
{
painter->fillRect(x*SIDE_LENGTH,y*SIDE_LENGTH,SIDE_LENGTH,SIDE_LENGTH,Qt::red);
}
//food.h
#include <QPainter>
#include "gameobject.h"
class Food : public GameObject
{
public:
Food(){}; //函数体为空可以内联
~Food(){};
void paint(QPainter *painter);
};
//food.cpp
#include "food.h"
const int SIDE_LENGTH = 20;
void Food::paint(QPainter *painter)
{
painter->setPen(Qt::black);//设置线条颜色
painter->setBrush(QBrush(Qt::yellow));//设置边框颜色
//绘制圆形
painter->drawEllipse(x*SIDE_LENGTH,y*SIDE_LENGTH,SIDE_LENGTH,SIDE_LENGTH);
}
这下看起来,子类的代码更加简洁了,这时候再次点击运行,窗口上仍然可以正常地显示蛇方块。
游戏规则函数
新的分类分完了,就可以回溯一下之前的问题了。原先的问题是这样来的:
静态的视图–>动态的模型–>存储蛇的链表–>蛇的分类
现在完成蛇的分类以后问题栈会变成这样:
静态的视图–>动态的模型–>存储蛇的链表
因此接下来要完成蛇的链表代码了,首先解决变量的创建和销毁问题,代码如下:
//model.h
#include <QList>
#include "snake.h"
#include "food.h"
class Model
{
public:
Model();
~Model();
private:
QList<Snake*> *snake_chain;// 链表内存放Snake指针,保存new出来的Snake实例
Food *food; //全局唯一食物
};
//model.cpp
#include "model.h"
Model::Model()
{
snake_chain = new QList<Snake*>;
food = new Food();
}
Model::~Model()
{
// 在释放链表之前要把链表内所有蛇方块都释放掉
QList<Snake*>::iterator iter = snake_chain->begin();
for(;iter<snake_chain->end();iter++)
delete *iter;
delete snake_chain;
delete food;
}
然后可以考虑一下有什么基础的游戏规则,比如:
蛇的移动函数、吃掉食物函数、撞到自身函数、随机产生食物函数
首先,在头文件中构想好函数模板,即先考虑函数的输入(参数)和输出(返回值)
// model.h
class Model
{
public:
//...
// 枚举方向
enum Direction{UP,DOWN,LEFT,RIGHT};
// 新增4个函数
void move(int direction); // 向前移动
bool hasEatenFood(); // 判断是否吃到食物
bool willCrushed(int direction); // 判断是否会碰到障碍
void generateFood(); // 随机生成食物
private:
//...
};
然后,开始依照顺序来发现和解决其中的问题即可。
移动函数,可以采用加头去尾的方式,如果吃了食物则不去掉尾巴
// model.cpp
void Model::move(int direction){
Snake *head = snake_chain->first(); //获取现在的头部
Snake *newHead = new Snake(); //创建新头部
// 设置新头部的位置
switch (direction) {
case Direction::UP:
newHead->setPos(head->getX(),head->getY()-1);
break;
case Direction::DOWN:
newHead->setPos(head->getX(),head->getY()+1);
break;
case Direction::LEFT:
newHead->setPos(head->getX()-1,head->getY());
break;
case Direction::RIGHT:
newHead->setPos(head->getX()+1,head->getY());
break;
default:
break;
}
snake_chain->push_front(newHead);
// 正常移动时需要去掉尾巴(即吃食物不去尾巴)
if(!hasEatenFood())
{
Snake* tail = snake_chain->takeLast();
delete tail;
}
}
这里可以不用关心判断食物函数是如何实现的,就可以直接调用。
下面马上编写判断食物的函数,可以不用关心它是如何被调用的。
这就是函数封装的好处。
// model.cpp
bool Model::hasEatenFood(){
Snake *head = snake_chain->first();
return head->getX()==food->getX() && head->getY()==food->getY();
}
之后是碰撞和食物生成函数,这两者都要检查是否会与蛇身冲突
// model.cpp
#include<QTime>
const int AXIS_X = 40;
const int AXIS_Y = 30;
bool Model::willCrushed(int direction){
// 获取蛇头下一个位置
Snake *head = snake_chain->first();
int next_x = head->getX();
int next_y = head->getY();
switch (direction) {
case Direction::UP:
next_y--;
break;
case Direction::DOWN:
next_y++;
break;
case Direction::LEFT:
next_x--;
break;
case Direction::RIGHT:
next_x++;
break;
default:
break;
}
// 检查是否有蛇身
for(QList<Snake*>::iterator iter = snake_chain->begin();
iter < snake_chain->end();iter++)
{
if( (*iter)->getX()==next_x && (*iter)->getY()==next_y )
return true;
}
return false;
}
void Model::generateFood(){
qsrand(QTime(0,0,0).secsTo(QTime::currentTime())); //设置种子
int x,y; //食物新的位置
bool isValidated; //是否与蛇冲突
do{
// 随机生成x和y,假定其正确
x = qrand() % AXIS_X;
y = qrand() % AXIS_Y;
isValidated = true;
// 如果冲突则不正确,重新循环
for(QList<Snake*>::iterator iter = snake_chain->begin();
iter < snake_chain->end();iter++)
{
if( (*iter)->getX()==x && (*iter)->getY()==y ){
isValidated = false;
break;
}
}
}while(!isValidated);
food->setPos(x,y);
}
记得要在移动那边调用生成新食物的方法
// model.cpp
void Model::move(int direction){
// ...
if(!hasEatenFood())
{
Snake* tail = snake_chain->takeLast();
delete tail;
}
else
generateFood();
}
这时点击运行程序测试一下代码,如果编译通过,则代表语法没有错误。
界面与模型联动
现在视图那里还是显示的测试的代码,并没有显示模型的内容。
那么下面要把模型的内容显示在视图上,首先是模型的初始化:
Model::Model()
{
snake_chain = new QList<Snake*>;
food = new Food();
init(); //在构造函数中初始化
}
// 新增一个init函数,头文件也需要增加
void Model::init(){
Snake *snake1 = new Snake();
Snake *snake2 = new Snake();
Snake *snake3 = new Snake();
snake1->setPos(10,10);
snake2->setPos(11,10);
snake3->setPos(12,10);
snake_chain->push_back(snake1);
snake_chain->push_back(snake2);
snake_chain->push_back(snake3);
// 先生成蛇,后生成食物
generateFood();
}
为了把数据提交给view绘画,新增两个函数返回模型里的数据
// model.h
class Model
{
public:
//...
QList<Snake*>* getSnakeChain(){return snake_chain;}
Food* getFood(){return food;}
private: //数据是私有的,所以函数需要是公有的
QList<Snake*> *snake_chain;
Food *food;
};
在View里创建唯一的一个Model实例,作为与model匹配的模型:
// view.cpp
View::View(QWidget *parent)
: QWidget(parent)
, ui(new Ui::View)
{
//...
model = new Model(); //成员变量
}
View::~View()
{
//...
delete model;
}
在paintEvent里获取这个model的数据,并绘制出来
// view.cpp
void View::paintEvent(QPaintEvent *event)
{
QPainter painter(this);
// ...绘制背景代码
model->getFood()->paint(&painter);
QList<Snake*>* snake_chain = model->getSnakeChain();
for(QList<Snake*>::iterator iter = snake_chain->begin();
iter < snake_chain->end();iter++)
(*iter)->paint(&painter);
}
多次点击运行测试程序,可以发现,每次生成食物的位置都不同了。
但是现在蛇仍然动不起来,看不出游戏规则是否正确。那么下面会编写一个简单的控制器,来推动整个程序的运行。
4. 计时器与控制器
计时器循环驱动
新建一个Controller类,并在其中添加一个QTimer的成员变量。在构造函数里初始化,在析构函数里释放。
// controller.h
class Controller
{
public:
Controller();
~Controller();
private:
QTimer *timer;
};
// controller.cpp
Controller::Controller()
{
timer = new QTimer();
}
Controller::~Controller()
{
delete timer;
}
自定义一个gameRunning()槽函数来管理游戏流程,然后使用Qt的信号槽机制连接。
Qt信号槽机制
这是Qt特有的信息传输机制,可以将信号(signal)函数广播出去,也可以用槽(slot)函数接受某个信号。这样可以动态地连接或者断开两个函数的流程,也方便进行异步编程。
为了能让QTimer可以成功地驱动自定义的代码,需要用到这个信号槽机制。
而使用信号槽,必须要先继承QObject类
// controller.h
class Controller : public QObject
{
Q_OBJECT // 必须加上QObject的宏定义
public:
//...
};
然后定义槽函数
// controller.h
class Controller : public QObject
{
Q_OBJECT // 必须加上QObject的宏定义
public:
//...
public slots:
void gameRunning();
private:
//...
};
在构造函数中连接计时器的函数和自定义的函数
// controller.cpp
Controller::Controller()
{
timer = new QTimer();
QObject::connect(timer,&QTimer::timeout,this,&Controller::gameRunning);
}
为了控制视图和模型,需要将Model和View实例作为参数传进来,为了防止头文件循环调用,可以先将其设为void指针再转换回来
// controller.h
class Controller : public QObject
{
//...
public:
Controller(void *view,Model *model);
//...
private:
Model *model;
void *view;
QTimer *timer;
};
// controller.cpp
Controller::Controller(void *view,Model *model)
{
timer = new QTimer();
this->view = view;
this->model = model;
QObject::connect(timer,&QTimer::timeout,this,&Controller::gameRunning);
}
实现简单的移动功能,在构造函数里初始化计时器并启动
// controller.cpp
Controller::Controller(void *view,Model *model)
{
timer = new QTimer();
this->view = view;
this->model = model;
timer->setInterval(300); //设定计时器间隔(毫秒)
timer->setSingleShot(false); //重复触发
QObject::connect(timer,&QTimer::timeout,this,&Controller::gameRunning);
timer->start();
}
void Controller::gameRunning()
{
model->move(Model::LEFT); // 一直让其向左走
((View*)view)->update(); // 通知窗口刷新界面
}
在View中新建Controller实例,并传入View和Model
// view.cpp
View::View(QWidget *parent)
: QWidget(parent)
, ui(new Ui::View)
{
//...
controller = new Controller(this,model);
}
View::~View()
{
delete controller;
//...
}
点击运行,可以发现蛇已经可以正常运动了。
emm…很明显程序还有bug,移动过程中没有考虑边界问题。修改一下代码。
void Model::move(int direction){
Snake *head = snake_chain->first(); //获取蛇头
Snake *newHead = new Snake();
// 获取下一个头的位置
int next_x = head->getX();
int next_y = head->getY();
switch (direction) {
case Direction::UP:
next_y--;
break;
case Direction::DOWN:
next_y++;
break;
case Direction::LEFT:
next_x--;
break;
case Direction::RIGHT:
next_x++;
break;
default:
break;
}
// 判断是否穿墙
if(next_x<0)
next_x = AXIS_X-1;
else if(next_x>=AXIS_X)
next_x = 0;
if(next_y<0)
next_y = AXIS_Y-1;
else if(next_y>=AXIS_Y)
next_y = 0;
//加入新蛇头
newHead->setPos(next_x,next_y);
snake_chain->push_front(newHead);
//判断食物是否被吃
if(!hasEatenFood())
{
Snake* tail = snake_chain->takeLast();
delete tail;
}
else
generateFood();
}
bool Model::willCrushed(int direction){
//...
if(next_x<0)
next_x = AXIS_X-1;
else if(next_x>=AXIS_X)
next_x = 0;
if(next_y<0)
next_y = AXIS_Y-1;
else if(next_y>=AXIS_Y)
next_y = 0;
//...
}
保存代码并再次运行程序,这次蛇可以正常地进行穿墙了。
响应键盘事件
和之前的paintEvent是一样的,Qt的窗口同样可以响应键盘事件。但为了符合MVC的分类,需要把任务交给Controller。
在Controller里自定义keyPress函数,并让View调用:
// controller.h
class Controller : public QObject
{
//...
private:
volatile int direction; // volatile保证数据最新
//...
};
// controller.cpp
void Controller::keyPress(QKeyEvent *event)
{
int key = event->key();
switch (key) {
case Qt::Key_Up:
direction = Model::UP;
break;
case Qt::Key_Down:
direction = Model::DOWN;
break;
case Qt::Key_Left:
direction = Model::LEFT;
break;
case Qt::Key_Right:
direction = Model::RIGHT;
break;
}
}
// view.cpp
void View::keyPressEvent(QKeyEvent *event)
{
controller->keyPress(event);
}
因为键盘事件和计时器互相之间是异步进行的,所以需要共享中间变量。
这时,运行程序,已经可以用键盘操控了!
完善程序
增加开始和暂停功能
// controller.cpp
void Controller::keyPress(QKeyEvent *event)
{
int key = event->key();
switch (key) {
//...
case Qt::Key_Return: // 回车键控制游戏开始和暂停
if(timer->isActive())
timer->stop();
else
timer->start();
break;
}
}
蛇碰到自身会游戏结束,自动重置
// controller.cpp
void Controller::gameRunning()
{
int direction = this->direction;
// 只有不撞自己才能移动,否则就游戏结束
if(!model->willCrushed(direction))
{
model->move(direction);
}
else
{
timer->stop();
model->init();
}
((View*)view)->update();
}
至此所有功能都实现完成了,再次点击运行程序,可以编译通过了。但是作为一个负责的程序员,应当进一步测试程序bug,优化使用体验。
5. 测试程序,解决bug,优化体验
移动控制问题
方向控制bug
多次按键测试之后,很快会发现游戏突然重置。原因是不能让蛇转成相反的方向移动,也就是其实只有向左转和向右转两个方向可以控制。
因此,可以将方向分为“当前方向”和“下一个方向”两种状态。
这里可以利用enum的机制,把两个冲突的方向隔开
代码如下:
// model.h
class Model
{
public:
// 新增一个EMPTY把其分为两组
enum Direction{UP,DOWN,EMPTY,LEFT,RIGHT};
//,,,
// controller.cpp
Controller::Controller(void *view,Model *model)
{
// 记得初始化方向的值
current_direction = Model::LEFT;
next_direction = Model::LEFT;
// ...
}
void Controller::gameRunning()
{
// 这样绝对值等于1的两个方向必定冲突
if(abs(current_direction-next_direction)<=1)
next_direction = current_direction;
current_direction = next_direction;
//...
}
修改完成后再次测试程序,OK,bug消失,但是程序还是显得生硬。
移动加速
尝试加快蛇的移动速度,看看会不会有所好转
间隔缩短到200毫秒
timer->setInterval(200);
虽然蛇的移动变快了,但是仍然有时候会有控制失灵的感觉。这有可能是因为按键更新速度太快了,以至于某些按键没有来得及处理就被改变了。
按键缓冲
增加一个变量用来缓冲连续的输入,键盘第一次输入会被读入缓冲,不可修改。等到缓冲里的数据被消耗后,缓冲新的输入。
函数完整代码:
// controller.cpp
void Controller::gameRunning()
{
// 新增cache_direction成员变量,-1为无缓冲
if(cache_direction<0)
cache_direction = next_direction;
if(abs(current_direction-cache_direction)<=1)
cache_direction = current_direction;
current_direction = cache_direction;
cache_direction = -1;
if(!model->willCrushed(current_direction))
{
model->move(current_direction);
}
else
{
timer->stop();
model->init();
init();
}
((View*)view)->update();
}
void Controller::keyPress(QKeyEvent *event)
{
int key = event->key();
switch (key) {
case Qt::Key_Up:
// 如果无缓冲,则读入缓冲
if(cache_direction<0)
cache_direction = Model::UP;
// 记录最后一次按键,
next_direction = Model::UP;
break;
case Qt::Key_Down:
if(cache_direction<0)
cache_direction = Model::DOWN;
next_direction = Model::DOWN;
break;
case Qt::Key_Left:
if(cache_direction<0)
cache_direction = Model::LEFT;
next_direction = Model::LEFT;
break;
case Qt::Key_Right:
if(cache_direction<0)
cache_direction = Model::RIGHT;
next_direction = Model::RIGHT;
break;
case Qt::Key_Return:
if(timer->isActive())
timer->stop();
else
timer->start();
break;
}
}
现在感觉已经“如鱼得水”了!嗯…很长的“鱼”…
蛇身增长
仔细观察可以发现,蛇在吃食物前会停顿一下。这是因为蛇身增长是在移动到那一格之前增长的。那么加一个标志位,在下一次移动增长即可。
// model.cpp
void Model::move(int direction){
//...
// 修改原来的增长代码
if(takeTail) //初始化为true,会删除尾巴
{
Snake* tail = snake_chain->takeLast();
delete tail;
}
else
{
generateFood();
takeTail = true;
}
if(hasEatenFood())
takeTail = false;
}
修改后发现可以在吃食物的同时向前移动,略微流畅了一点。
碰撞算法问题
每次检测是否与蛇身冲突都要检查一遍蛇的全身。设想如果玩到后面蛇身很长,检查蛇身是非常浪费时间的。
那么可以再用一个二维数组来存储地图上有无物体的状态,然后同步数组和蛇链表的数据。
创建和销毁二维数组:
// model.cpp
Model::Model()
{
//...
block_state = new bool*[AXIS_X];
for(int i=0;i<AXIS_X;i++)
{
block_state[i] = new bool[AXIS_Y];
memset(block_state[i],false,sizeof(bool)*AXIS_Y);
}
}
Model::~Model()
{
for(int i=0;i<AXIS_X;i++)
delete [] block_state[i];
delete [] block_state;
//...
}
将增加蛇头和移出尾巴封装成函数,同步链表和数组的状态
// model.cpp
void Model::addHead(int x,int y)
{
Snake *newHead = new Snake();
newHead->setPos(x,y);
snake_chain->push_front(newHead);
block_state[x][y] = true;
}
void Model::removeTail()
{
Snake* tail = snake_chain->takeLast();
delete tail;
block_state[tail->getX()][tail->getY()] = false;
}
替换碰撞检测的代码。
完整代码:
// model.cpp
bool Model::willCrushed(int direction){
// 获取蛇头下一个位置
Snake *head = snake_chain->first();
int next_x = head->getX();
int next_y = head->getY();
switch (direction) {
case Direction::UP:
next_y--;
break;
case Direction::DOWN:
next_y++;
break;
case Direction::LEFT:
next_x--;
break;
case Direction::RIGHT:
next_x++;
break;
default:
break;
}
if(next_x<0)
next_x = AXIS_X-1;
else if(next_x>=AXIS_X)
next_x = 0;
if(next_y<0)
next_y = AXIS_Y-1;
else if(next_y>=AXIS_Y)
next_y = 0;
// 检查是否有蛇身
if(block_state[next_x][next_y])
return true;
else
return false;
}
void Model::generateFood(){
qsrand(QTime(0,0,0).secsTo(QTime::currentTime())); //设置种子
int x,y; //食物新的位置
bool isValidated; //是否与蛇冲突
do{
// 随机生成x和y,假定其正确
x = qrand() % AXIS_X;
y = qrand() % AXIS_Y;
isValidated = true;
// 如果冲突则不正确,重新循环
if(block_state[x][y])
isValidated = false;
}while(!isValidated);
food->setPos(x,y);
}
运行程序,编译通过。我个人暂时没时间测试玩到后期会不会出bug,有时间的小伙伴可以自己测一下。
增加界面提示
一个完整的游戏最好配上清晰的开始和结束提示,下面会做一些简单的美化。
在View内增加两个绘制GUI的方法,可以利用QFontMetrics类获取字符串长度,以便居中:
void View::paintGameStart(QPainter *painter)
{
// 背景颜色
painter->fillRect(0,200,800,200,QColor(128,128,128,196));
// 主标题
painter->setPen(QPen(QColor(255,255,255,196)));
QFont font_title("宋体",36);
QFontMetrics fm_title(font_title);
QString str_title = "贪吃蛇";
painter->setFont(font_title);
int x = (800-fm_title.width(str_title))/2;
int y = 200 + fm_title.height()*1.5f; //绘制基线之上
painter->drawText(x,y,str_title);
// 副标题
QFont font_subtitle("宋体",16);
QFontMetrics fm_subtitle(font_subtitle);
QString str_subtitle = "方向键移动 回车键开始游戏";
painter->setFont(font_subtitle);
x = (800-fm_subtitle.width(str_subtitle))/2;
y = 400 - 0.5f*fm_title.height(); //绘制基线之上
painter->drawText(x,y,str_subtitle);
}
void View::paintGameOver(QPainter *painter)
{
// 背景颜色
painter->fillRect(0,200,800,200,QColor(128,128,128,196));
// 主标题
painter->setPen(QPen(QColor(255,255,255,196)));
QFont font_title("宋体",36);
QFontMetrics fm_title(font_title);
QString str_title = "游戏结束";
painter->setFont(font_title);
int x = (800-fm_title.width(str_title))/2;
int y = 200 + fm_title.height()*1.5f;
painter->drawText(x,y,str_title);
// 副标题
QFont font_subtitle("宋体",16);
QFontMetrics fm_subtitle(font_subtitle);
QString str_subtitle = "按回车键重新开始游戏";
painter->setFont(font_subtitle);
x = (800-fm_subtitle.width(str_subtitle))/2;
y = 400 - 0.5f*fm_title.height();//绘制基线之上
painter->drawText(x,y,str_subtitle);
}
在Controller内增加两个标志位判断游戏开始和结束,然后View检查Controller的状态来决定要不要显示界面。
// controller.cpp
// 省略变量初始化过程
bool Controller::isFirstStart()
{
return b_isFirst; //判断初次运行的标志位
}
bool Controller::isGameOver()
{
return b_isGameOver; //判断是否游戏结束
}
void Controller::restart(){
if(timer->isActive())
{
timer->stop();
}
else
{
if(b_isGameOver)
{
model->init();
init();
b_isGameOver = false;
}
timer->start();
b_isFirst = false;
}
}
//view.cpp
void View::paintEvent(QPaintEvent *event)
{
//...
if(controller->isFirstStart())
paintGameStart(&painter);
else if(controller->isGameOver())
paintGameOver(&painter);
}
最终效果:
总结
可以发现,窗口里面绘制的东西增多以后,程序启动会有些卡顿。想要保证能绘制更复杂的图形同时,又能保持性能,需要学习一下Qt的opengl的功能。
并且可以将蛇或者背景换成图片绘制出来,会更加美观。
如果对于程序设计和程序优化还想要有更深入地了解,请在网络上搜索“设计模式”和“数据结构”的相关内容。
完整代码已上传gitee:https://gitee.com/pinesty/snake-game
希望这篇文章能让小伙伴们更加轻松地应对实际工作中遇到的问题,感谢阅读!