BallMove:基于Qt的GUI小项目

BallMove:基于Qt的GUI小项目

简介

在暑假学科目二的时候,看着 Qt学习之路2 学了Qt的绘制系统,做这个项目的初心就是用来巩固学到的Qt绘制系统。

今天写下这篇博客,是为了记录下自己在这个项目中学到的东西,方便自己以后使用Qt的时候能够快速上手。

这个项目基于QGraphicsScene + QGraphicsItem + QGraphicsView, 模拟了一个小球在现实世界中的运动。

项目地址:cfla1638/BallMove

设定

功能:实现了实心球的运动。

考虑的因素有:

  1. 用户给予的加速度
  2. 重力
  3. 摩擦力
  4. 空气阻力

细节:

该项目模拟了一个半径0.04米的实心钢铁球,其密度为 7850 kg / m^3^ 。

重力加速度为 9.8 m/s^2^ 。

静摩擦系数为 0.3。

空气阻力使用公式:F = 1/2 CρSv^2^ ,其中C取0.3,ρ取1.293,S取 3.14 0.04^2^ 。

尺寸换算:1m = 250px。

基础知识

  1. Qt的绘制系统由QGraphicsScene + QGraphicsItem + QGraphicsView 相互配合使用。

    QGraphicsScene 提供一个场景(Scene),所有要显示的实体都可以放到这个场景中。

    QGraphicsItem 是在QGraphicsScene 中实体对象类的父类,在本项目中的实体类都要继承这个类。继承这个类的实体可以被添加到QGraphicsScene,进行显示和管理。

    QGraphicsView 提供了一个观察的视角,配合QGraphicsScene 进行显示。这个类可以被设为QMainWindowCentralWidget

  2. 只有QObject 的子类可以使用信号槽的机制

  3. QTimer类提供计时器功能,使用timer.start(sec) 功能开始计时,这里timer是一个QTimer类的一个对象,每经过sec时间后,timer就会发出一个timeout() 信号。

  4. 想要实现物体的运动,就要让物体的位置在每一帧里进行改变/刷新(本程序的帧率是120),而QGraphicsScene 提供一个advance() 函数,该函数会调用所有在场景里的QGraphicsItem 对象的advance() 函数,因此我们只要每经过 1000 / 120 毫秒 就调用一次QGraphicsScene::advance() 函数,就可以进行场景刷新,从而实现物体的运动。

  5. 每个QGraphicsItem::advance(int phase) 都会被一个QGraphicsScene::advance() 调用两次。在第一次时,Item已经准备好刷新,此时传入的phase = 0。第二次QGraphicsScene::advance() 将phase = 1 传入函数并调用。

    基于此,我们只需要在phase = 1是进行处理,phase = 0 是我们什么也不做。否则我们程序的帧率会变成原来的二倍。

  6. Qt 的坐标系统。在本程序中,我们用到了Qt的两套坐标。第一个是在QGraphicsScene 中的全局坐标系,它记录了每个QGraphicsItem 的位置。第二个是每个QGraphicsItem 对象都会有的本地坐标系。在我们绘制每个部件时,我们就会基于本地坐标系绘制。

  7. 每个继承了QGraphicsItem 的类都要重载以下四个函数。

    1
    2
    3
    4
    QRectF boundingRect() const;
    QPainterPath shape() const;
    void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr);
    void advance(int phase);

    boundingRect() 返回一个包裹住QGraphicsItem 对象的矩形(比对象大一点或很多)。这个矩形使用的是全局坐标系,用于碰撞检测等功能。

    shape() 返回图型的准确形状。如对于我们的小球来说,这个函数返回一个圆轨迹。

    void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr)

    该函数一般由QGraphicsScene 自动调用,绘制该项目。

    这个函数一般这样写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void Ball::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
    {
    painter->save(); // 保存画笔状态

    //设置新的画笔状态
    painter->setRenderHint(QPainter::Antialiasing); // 设置反走线(抗锯齿)
    painter->fillPath(shape(), Qt::blue); // 绘制图型

    painter->restore(); // 还原画笔状态
    }

    我们要保证该函数调用过之后,画笔的状态不被改变,因此使用save和restore。

    painter->fillPath(shape(), Qt::blue); 这一行绘制我们在shape() 中返回的图型,在此之前我们可以设置画笔的状态。

  8. 编写头文件的建议

    如果我们在头文件中#include 了另一个文件,那么我们的这个头文件就依赖另一个文件。如果我们依赖的文件发生改变,本文件也要重新编译。

    为了减少编译时间,我们要减少头文件的依赖。为此我们使用前置声明法。

    即,在头文件中,我们尽量使用需要使用到的类的指针,并将此类的声明写在头文件前,这样我们就不需要在头文件中include 这个类,也就减少了头文件的依赖。

项目设计

首先来看一下运行效果:

image-20220822074244433

1
2
3
4
5
6
7
8
9
10
在这颗树中,一个类是另一个类的子树,就表示这个类是另一个类的私有对象
另外,这棵树也为我们展示了本项目的设计思路。
└─MainWindow
├─Controller
│ ├─Ball
│ ├─Balldata
│ ├─Ground
│ └─QGraphicsScene - Reference
├─QGraphicsScene
└─QGraphicsView

自下而上,首先来看Controller下的三个类,

Controller下有三个类,分别是Ball, Balldata, Ground,他们都是QGraphicsItem 的子类,分别对应运行结果中的蓝色小球,上面绿色的信息显示和下面黑色的地面。

这三个类定义了自身的形状和他们的运动逻辑。以Ball为例,Ball类有四个上述提到的函数,用来绘制他的形状,还有记录它自身加速度和速度的私有类型。

值得一提的是,Controller类有一个引用,QGraphicsScene - Reference,它其实是MainWindow的私有对象,但由于Controller类需要经常使用这个对象,为了方便,我们就将它的引用放在了Controller类中。

接着是Controller类。

顾名思义,Controller类是我们程序的控制器。

Controller类可以控制程序的开始结束,控制界面的刷新,对Qt的时间进行处理。

最后我们看一下最外层的MainWindow类,他是程序的主窗口。它下面的QGraphicsScene和QGraphicsView 分别是画布和观察窗口。

当程序开始执行时,首先执行MainWindow类的构造函数,分别构造QGraphicsScene,QGraphicsView和Controller。在Controller类进行构造时,Controller下的物体就被添加到了Scene中,程序也就开始运行了。当MainWindow下的对象都构造完成时。main.cpp 的QMainWindow::show()QApplication :: exec() 函数分别将程序主窗口显示,并且开始程序的事件循环,接受事件,交给Controller进行处理。

项目实现

文件结构

首先看一下我们的文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BallMove
- BallMove.pro
Headers
- ball.h
- balldata.h
- constants.h
- controller.h
- ground.h
- mainwindow.h
Sources
- ball.cpp
- balldata.cpp
- controller.cpp
- ground.cpp
- main.cpp
- mainwindow.cpp

Ball类的实现

首先看一下ball类的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Ball : public QGraphicsItem
{
public:
Ball(qreal x, qreal y);

QRectF boundingRect() const;
QPainterPath shape() const;
void advance(int phase);
QPointF getPos(){return pos();}

int forceCount = 4;
qreal a[2][4];
qreal vx = 0;
qreal vy = 0;

protected:
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr);

};

首先我们来看Ball类的私有变量:

由于我们运动的平面是二维的,因此我们使用一个二维数组qreal a[2][4] 来存储加速度。其中qreal就是double类型。如果我们想要访问小球x方向的空气阻力产生的加速度,我们可以使用在constants.h 中定义的枚举类型,即a[xAxis][AirResistance] 。如果想要引用y方向的重力加速度,就使用a[yAxis][Gravity]

qreal vx, vy 即小球的x方向速度,和y方向的速度。

int forceCount 即我们考虑的力的数目,这个变量用于将这些力合成的时候进行计数。

接着我们来看一下Ball类中声明的函数:

第一个函数getPos()很好理解,即返回小球在全局坐标系中的坐标。

接着我们仔细考察一下之前我们在基础知识部分介绍的四个函数:paint() shape() advance()

boundingRect()

boundingRect :

1
2
3
4
QRectF Ball::boundingRect() const
{
return QRectF(-BALL_SIZE * 2, -BALL_SIZE * 2, BALL_SIZE * 4, BALL_SIZE * 4);
}

QRectF类即矩形类,这个函数返回了一个包裹小球矩形。需要注意的是,这个矩形的坐标是在本地坐标系的坐标。

BALL_SIZE 定义在constants.h 代表小球的直径。

shape() :

1
2
3
4
5
6
QPainterPath Ball::shape() const
{
QPainterPath path;
path.addEllipse(QPointF(0, 0), BALL_SIZE / 2, BALL_SIZE / 2);
return path;
}

这个函数返回了一个绘画轨迹类QPainterPath,并在里面添加了一个圆形轨迹。需要注意的是,这个坐标也是在小球本地坐标系中的。

paint()

1
2
3
4
5
6
7
8
9
void Ball::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
painter->save();

painter->setRenderHint(QPainter::Antialiasing); // 设置反走线(抗锯齿)
painter->fillPath(shape(), Qt::blue);

painter->restore();
}

在第五行设置了画笔的抗锯齿,紧接着下一行绘制了小球的准确形状。

advance()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void Ball::advance(int phase)
{
// 只有phase为1时进行处理
if (!phase) return ;

// 计算合力
qreal sumAx = 0, sumAy = 0;
for (int i = 0; i < forceCount; i++)
{
sumAx += a[xAxis][i];
sumAy += a[yAxis][i];
}

if (sumAx) vx += (sumAx / FRAME_RATE);
if (sumAy) vy += (sumAy / FRAME_RATE);

qreal x = fmod(pos().rx() + vx / FRAME_RATE, 1000), y = fmod(pos().ry() + vy / FRAME_RATE, 1000);

// 处理小球越界
if (x < 0) x += 1000;
if (y < BALL_SIZE / 2) y = BALL_SIZE / 2;

// 设置新位置
setPos(QPointF(x, y));
update(); // 更新显示小球
}

由于我们设置了帧率为120帧,因此这个函数在每秒钟会被调用120次。

在这个函数里,我们依次计算了小球受到的合力,改变了小球的速度,根据小球的速度对小球进行了移动。

需要注意,一、因为我们的速度、加速度定义为m/s和m/s^2^。 因此我们在更新速度和位置时,要将速度和加速度除以帧率,这样经过120次调用,速度和加速度才改变了1s的量。

二、在设置了小球的位置之后,需要使用update() 函数更新小球的位置。

constants.h 参数存储文件

constants.h 文件中存放了项目的各种参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const int FRAME_RATE = 120;         // 帧率:120帧  帧率过低时碰撞会穿透地面
const int BALL_SIZE = 20; // 球的直径:20px

// 400 对应 112 重力
// 3000 对应 2450 重力
const int X_SPEED = 1500; // 左右键给球的加速度
const int Y_SPEED = 3000; // 上下键给球的加速度

const int FRICTION = 735; // 摩擦力大小
const int FRICTION_SENSITIVITY = 2; // 摩擦力敏感度,当速度的值大于此值时,小球受摩擦力

// 112 对应1.75 米的人
// 2450 对应 8cm 的实心球
const int GRAVITY = 2450; // 重力大小
const double M = 2.1038; // 小球质量

enum Directions{xAxis, yAxis}; // x, y轴方向
enum Force{User, Gravity, Friction, AirResistance}; // 力的四个维度,用户给出的力,重力,摩擦力,空气阻力

需要注意是两个枚举类型,通过定义枚举类型,可以使用枚举变量引用数组元素,可读性更好。

Balldata & ground

这两个类都继承了QGraphicsItem 和Ball类类似,因此我们不在赘述。

Controller类

首先看一下Controller类的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Controller : QObject
{
Q_OBJECT

public:
Controller(QGraphicsScene & scene, QObject * parent = 0);
void track(bool);
void gravity();

public slots:
void drawLineOfTrack();
void resume();
void pause();
void advance();

protected:
bool eventFilter(QObject *atched, QEvent *event);

private:
void handleKeyPressed(QKeyEvent * event);
void handleKeyReleased(QKeyEvent * event);

QTimer timer;
Ball * ball;
QGraphicsScene & scene;
Ground * ground;
ballData * data;
};

Controller类有以下几点功能:

  • 处理键盘事件:如上下左右移动,打开/关闭 轨迹显示,清屏。

    相关地函数有:

    1
    2
    3
    bool eventFilter(QObject *atched, QEvent *event);
    void handleKeyPressed(QKeyEvent * event);
    void handleKeyReleased(QKeyEvent * event);
  • 控制程序的开始与结束

    相关地函数有:

    1
    2
    void resume();
    void pause();
  • 控制程序的一步步地推进,即让小球动起来。

    相关地函数有:

    1
    void advance();
  • 其他功能

    相关地函数有:

    1
    2
    3
    void track(bool);
    void gravity();
    void drawLineOfTrack();

在这里我们只介绍advance() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
void Controller::advance()
{
scene.advance(); // 首先调用场景的advance()

// 获取碰撞信息
static bool lastStatus = false; // 上一帧是否碰撞
static bool nowStatus = false; // 当前是否处于碰撞
nowStatus = ball->collidesWithItem(ground);

//处理碰撞
if (nowStatus == true && lastStatus == false)
{
ball->a[yAxis][Gravity] = 0; // 碰撞时重力与支持力抵消
ball->vy = -(ball->vy * 0.667); // 回弹 2/3 的速度
ball->a[yAxis][User] = 0; // 竖直方向速度为0
}

// 处理摩擦力
if (nowStatus)
{
if (ball->vx > FRICTION_SENSITIVITY) ball->a[xAxis][Friction] = -FRICTION;
else if (ball->vx < -FRICTION_SENSITIVITY) ball->a[xAxis][Friction] = FRICTION;
else ball->a[xAxis][Friction] = 0;
}

// 没有碰撞的时候
if (!nowStatus)
{
// 摩擦力置零
ball->a[xAxis][Friction] = 0;
ball->a[yAxis][Gravity] = GRAVITY;
}

// 处理空气阻力
qreal sumV = pow(ball->vx, 2) + pow(ball->vy, 2);
if (fabs(sumV) > 10)
{
qreal airSum = 0.0009744 * sumV / 250;
if (sumV == 0) sumV = 1;
ball->a[xAxis][AirResistance] = (fabs(ball->vx) / (sqrt(sumV))) * airSum;
ball->a[yAxis][AirResistance] = (fabs(ball->vy) / (sqrt(sumV))) * airSum;

// 将力换算成加速度
ball->a[xAxis][AirResistance] /= M;
ball->a[yAxis][AirResistance] /= M;

if (ball->vx > 5) ball->a[xAxis][AirResistance] *= -1;
else if (ball -> vx < 10) ;
else ball->a[xAxis][AirResistance] = 0;

if (ball->vy > 5) ball->a[yAxis][AirResistance] *= -1;
else if (ball -> vy < 10) ;
else ball->a[yAxis][AirResistance] = 0;
}


lastStatus = nowStatus;
data->update(); // 更新数据显示
}

由于每次更新,我们都要对碰撞进行检测,因此我们首先调用场景地advance函数。

等运动结束之后,我们使用Qt地碰撞检测函数collidesWithItem()看小球是否接触地面。

接着我们根据碰撞检测地结果,依次处理摩擦力和空气阻力。

其实我们还可以将处理各种力地程序写到小球地advance()中,但为了让我们地程序更符合controller控的概念,我们为controller也添加了advance()函数,由它调用scene的advance()函数。

MainWindow 类

MainWindow类的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainWindow : public QMainWindow
{
Q_OBJECT

public:
MainWindow(QWidget *parent = nullptr);
~MainWindow();

private slots:
void adjustViewSize();

private:
void initScene();
QGraphicsView * view;
QGraphicsScene * scene;
Controller * controller;
};

唯一需要解释的函数是它的构造函数,其他的函数都会在构造函数里被调用。

1
2
3
4
5
6
7
8
9
10
11
12
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent),
scene(new QGraphicsScene(this)),
view(new QGraphicsView(scene, this)),
controller(new Controller(*scene, this))
{
setCentralWidget(view);
resize(750, 750);

initScene();
QTimer::singleShot(0, this, SLOT(adjustViewSize()));
}

在构造函数中,我们首先初始化了scene、view和controller,紧接着我们将view设为了窗口的centralWidget 并调整了窗口大小。

在倒数第二步,调用initScene 对scene进行调整。

以上的这些步骤都不会有什么问题,只有最后一步令人困惑:

QTimer::singleShot(0, this, SLOT(adjustViewSize()));

首先解释singleShot() 函数,这个函数的声明如下

void QTimer::singleShot(int msec, const QObject *receiver, const char *member)

它的含义是在msec 毫秒后调用receiver的槽函数member,这是一个很方便的函数,这样你就可以在不去手动计时的情况下达到同样的效果。

那么为什么要使用这个功能呢?

这里有两个解释

那么,QTimer::signleShot(0, ...)意思是,在下一次事件循环开始时,立刻调用指定的槽函数。在我们的例子中,我们需要在视图绘制完毕后才去改变大小(视图绘制当然是在paintEvent()事件中),因此我们需要在下一次事件循环中调用adjustViewSize()函数。这就是为什么我们需要用QTimer而不是直接调用adjustViewSize()。如果熟悉 flash,这相当于 flash 里面的callLater()函数。

这个解释来自《Qt学习之路2》 但我并不清楚这里的 “下一次事件循环” 的意思。

依我的理解,应该是这个意思:某个QObject 发出 paintEvent() 事件,但对这个事件的处理比较耗时,如果此时直接调用adjustViewSize() ,视图还没有绘制完成,自然就不能起到调节视图的效果。事实也是这样,如果直接调用adjustViewSize() ,我们会发现视图小小的挤在屏幕的中间。

后来我又在网上发现了这篇博客:QTimer::singleShot(0, this, slot函数); ,意思和我理解的大概相似。

但还是挖个坑,等我以后学精了一定回来解释清楚。

参考

C++ 头文件使用规范建议_恋喵大鲤鱼的博客-CSDN博客_c++头文件规范

Qt 学习之路 2(31):贪吃蛇游戏(1) - DevBean Tech World