Skip to content

Latest commit

 

History

History
706 lines (537 loc) · 36.6 KB

File metadata and controls

706 lines (537 loc) · 36.6 KB

六、使用 Qt 绘图

虽然许多应用只能使用内置小部件构建,但其他应用需要能够执行自定义绘图-例如,当您需要一两个自定义小部件时,或者您正在进行屏幕外呈现以编程方式在图形文件中创建图像时,或者您对构建完全不同的用户界面感兴趣时。 除了您可以使用 Qt Quick 执行的操作之外,Qt 还提供了对 C++ 中所有这些场景的支持。

在本章中,我们将了解 Qt 中的一般绘图需要了解的内容。 我们首先讨论QPainter,以及它如何使用QPaintDevice实例来抽象绘图功能。 我们将大体上了解这是如何工作的,然后给出屏幕外绘制位图以及创建与 Qt 小部件互操作的自定义小部件的具体示例。 在本章的后半部分,我们将介绍 Qt 为图形管理提供的一个更新、更低级别的抽象:QGraphicsViewQGraphicsScene提供的图形视图/图形场景架构,以及如何使用它在 C++ 中构建与 Qt 窗口小部件类互操作的应用,同时包含复杂的可视化层次结构。

在本章中,我们将介绍以下主题:

  • 开始在 Qt 中绘图
  • QPaintDevice个实例上使用QPainter绘制
  • 拉出屏幕
  • 创建自定义小部件
  • 图形视图框架简介

Throughout the chapter, don't forget you can always ask for Qt Creator's help when encountering an unfamiliar class or method. Qt Creator also comes with a number of examples you can look at; these are in the examples directory, under the directory in which you installed Qt.

技术要求

本章的技术要求包括 Qt 5.13.1 MinGW 64 位、Qt Creator 4.10.0 和 Windows 10。

代码文件可在以下链接中找到:https://github.com/PacktPublishing/Application-Development-with-Qt-Creator-Third-Edition

开始在 Qt 中绘图

我们在本章中介绍的所有材料都依赖于 Qt GUI 模块,该模块是 Qt 的一部分。 即使您正在编写命令行工具(比如处理图像文件),也需要通过将以下代码添加到.pro文件来将该模块包含在项目中:

QT += gui widgets 

当然,在您的 C++ 实现中,我们还需要包括我们正在使用的类的头文件。 例如,如果我们使用QImageQBitmapQPainter,请确保在 C++ 文件的顶部包含这些头文件,如下所示:

#include <QImage> 
#include <QPainter> 
#include <QBitmap> 

因为 Qt 的绘制实现使用底层窗口系统,所以任何执行图形操作的应用都必须使用QGuiApplication构建,它会将窗口系统作为启动的一部分进行初始化。

由于我们已经向项目中添加了所需的模块和标题,并在 Qt 项目中启用了绘图功能,因此让我们开始在下一节中绘制一些内容!

在 QPaintDevice 实例上使用 QPainter 绘图

从本质上讲,图形绘画需要两样东西:会画的东西和能画的东西。 Qt 将QPainter类定义为前者,将QPaintDevice定义为后者的类的接口。 您很少实例化每个类,但是如果您正在进行图形编程,则会经常使用这两个类;通常,您会有一个QPaintDevice子类的实例,向它请求其相关的QPainter,然后使用QPainter执行绘图。 这可能发生在您编写小部件时;例如,当您需要绘制小部件的内容时,会向您传递一个QPainter子类。

QPaintDevice有几个子类,如下所示:

  • QWidget:此类及其子类由小部件层次结构使用。
  • QImage:这是一个用于屏幕外图像的容器类,这些图像针对输入/输出和单个像素访问进行了优化。
  • QPixmap:这是一个用于屏幕外图像的容器类,这些图像针对与屏幕的交互进行了高度优化。
  • QBitmap:这是QPixmap的一个子类,位深度为 1,因此适用于单色图像。
  • QPicture:这是一种涂装设备,可记录QPainter绘图操作并可回放。

QPaintDevice子类有widthheight方法,分别以像素为单位返回绘制设备的宽度和高度;相应的widthMMheightMM方法以毫米为单位返回绘制设备的宽度和高度(如果已知)。 您还可以通过调用depth方法来获取QPaintDevice类的位深度。

我们将在接下来的每一节中进一步讨论如何获取QPaintDevice子类,因为我们看到了要在其上绘制的内容(例如,屏幕外的位图或自定义小部件)。 让我们转到QPainter,它是我们用来执行绘图的类。

QPainter通过使用绘制特定形状(点、线、多边形、椭圆、圆弧等)的方法以及控制QPainter如何执行所请求的实际绘制的许多设置来封装绘画的概念。

设置包括以下内容:

  • brush:这指示它应该如何填充形状。
  • backgroundMode:这表示背景应该是不透明的还是透明的。
  • font:表示绘制文本时应使用的字体。
  • pen:这表示它应该如何绘制形状的轮廓。

您还可以指定是否启用了视图变换,这使您可以设置QPainter实例在执行绘图时将应用的仿射变换。

Qt 允许您为QPainter实例指定可能在比例、旋转和原点方面与目标QPaintDevice类的坐标系不同的任意坐标系。 在执行此操作时,您可以将图形坐标系之间的仿射变换指定为变换矩阵,或通过单独的缩放、旋转和原点偏移参数来指定。 如您所料,默认设置为不转换。

Discussing transformations is beyond the scope of this section; for more details, see the Qt documentation on this topic at https://doc.qt.io/qt-5/qpainter.html#coordinate-transformations.

QBrushQPen类都使用QColor实例来指定颜色;使用QColor,您可以将颜色指定为 RGB、HSV 或 CMYK 值,或者通过颜色名称指定由可缩放矢量图形(SVG)颜色名称定义的颜色之一(请参阅http://www.december.com/html/spec/colorsvg.html上的列表)。 除颜色外,QBrush实例还指定样式、渐变和纹理;QPen实例指定样式、宽度、画笔、笔帽样式(在笔的端点上使用)和连接样式(在两个笔划连接时使用)。 两者都有简单的构造函数来设置对象的各个字段,或者您可以通过 setter 和 getter 方法指定它们。 例如,我们可以使用以下代码行创建一支green笔,其宽度为3像素,由一条带有圆形大写和连接的虚线组成:

QPen pen(Qt::green, 3, Qt::DashLine, Qt::RoundCap, Qt::RoundJoin); 

同样,要创建用绿色实心填充的QBrush,可以编写以下代码:

QBrush brush(Qt::green, Qt::SolidPattern);

QFont的操作类似,但当然,与画笔或钢笔相比,字体有更多的选项。 通常,将字体系列与字体大小和粗细一起传递给所需字体的构造函数。 Qt 有一种健壮的字体匹配算法,它试图将所需的字体系列与系统上实际可用的字体进行匹配,因为众所周知,一旦您放弃了常用的Times New RomanHelvetica等常用字体,就很难预测哪些字体随处可用。 因此,您获得的字体可能不完全是您请求的字体;您可以通过从您创建的QFont创建一个QFontInfo方法来获取有关字体的信息,如下所示:

QFont serifFont("Times", 10); 
QFontInfo serifInfo(serifFont); 

一旦设置了QPainter的画笔、钢笔和字体,绘制只需调用各种QPainter方法即可。 我不会一一列举它们,而是将您的注意力集中在这里的几个方面,然后向您展示一个使用其中一些的示例:

  • drawArc:这将绘制一条圆弧,从一个角度开始,并跨越一个矩形中的一个角度。 角是以度的十六分之一为单位测量的。
  • drawConvexPolygon:这将获取一个点列表,并绘制一个凸多边形。
  • drawEllipse:这将在矩形中绘制一个椭圆(要绘制圆形,请将矩形变为正方形)。
  • drawImage:这将绘制一幅图像(QImage),其中包含目标矩形、图像和源矩形。
  • drawLine:这将绘制一条线;drawLines将绘制一系列线。
  • drawPicture:这将绘制一幅图(QPicture)。
  • drawPixmap:这将绘制一个像素图(QPixmap)。
  • drawPointdrawPoints:它们绘制一个点或一组点。
  • drawPolygon:这将绘制一个(可能是凹的)多边形,它的点可以是点数组,也可以是QPolygon
  • drawPolyline:这将绘制一条多段线。
  • drawRect:这将绘制单个矩形;drawRects将绘制多个矩形。
  • drawText:这将绘制一个文本字符串。
  • fillPath:这将使用您传递的笔刷填充多边形路径。
  • fillRect:这将使用您传递的画笔绘制一个实心矩形。

为了方便这些方法,Qt 定义了助手容器类,包括QPointQLineQPolygon。 它们采用整数坐标;如果在绘制时(比如,在使用转换绘制时)需要更大的位置,则可以使用浮点变量QPointFQLineFQPolygonF

让我们通过画一张脸来看看所有这些在实践中是如何堆叠起来的。 给定一个QPainter类,我们可以按如下方式编写它:

void MainWindow::paintEvent(QPaintEvent *event)
{
    QPainter painter(this);
    QPen pen(Qt::black, 2, Qt::SolidLine);
    QBrush whiteBrush(Qt::white, Qt::SolidPattern);
    QBrush blackBrush(Qt::black, Qt::SolidPattern);
    QRect faceOutline(0, 0, 100, 100);
    painter.setPen(pen);
    painter.setBrush(whiteBrush);
    painter.drawEllipse(faceOutline);

上述代码首先定义实心黑色钢笔和实心黑白画笔,并将钢笔设置为我们创建的钢笔。

接下来,它创建一个每边 100 像素的正方形,并使用drawEllipse在其中绘制一个白色圆圈。 然后,我们画嘴巴,它是圆底部的半椭圆弧。 接下来,我们使用一个矩形绘制两个眼睛,每个眼睛都是一个实心圆。 最后,我们使用由三个点定义的两条线绘制鼻子,如下所示:

    QRect mouth(30, 60, 40, 20);
    painter.drawArc(mouth, 180 * 16, 180 * 16);  // Draw mouth
    QRect eye(25, 25, 10, 10);
    painter.setBrush(blackBrush);
    painter.drawEllipse(eye);  // Draw left eye
    eye = QRect(65, 25, 10, 10);
    painter.drawEllipse(eye);  // Draw right eye
    QPoint nosePoints[3] = {
        QPoint(50, 45),
        QPoint(40, 50),
        QPoint(50, 50) };
    painter.drawPolyline(nosePoints, 3);  // Draw nose
}

您可以在下面的屏幕截图中看到结果:

我们已经学会了如何在屏幕上画笑脸! 现在,让我们看看如何使用QPainter来拉出屏幕。

拉出屏幕

您可能想要画出屏幕的原因有很多:您可能想要组成一个图像集合并一个接一个地显示它们(这称为双缓冲,您可以这样做以避免在屏幕上绘制时屏幕绘制闪烁),或者编写一个直接生成图像文件的程序。

正如我在上一节中提到的,Qt 提供了几个用于屏幕外绘制的类,每个类都有不同的优点和缺点。 这些类是QImageQPixmapQBitmapQPicture。 在正常情况下,您需要在QImageQPixmap之间进行选择。

QImage is the class best suited for general-purpose drawing, where you're interested in loading the image from or saving the image to a file. If you're working with resources, combining multiple images, and doing a bit of drawing, QImage is the class you want to use.

另一方面,如果您主要出于显示性能或双缓冲的目的使用屏幕外渲染,则需要使用QPixmapQPixmap被优化为在底层窗口系统中使用数据结构,并且比QImage更快地与本机窗口系统互操作。 QBitmap只是定义单色位图的QPixmap的一个方便的子类。

QPicture是一个有趣的东西,它以与分辨率无关的格式记录绘图操作,您可以将其保存到文件中,稍后再重播。 如果要创建与平台无关的轻量级矢量图像,您可能需要这样做,但通常只使用适当分辨率的便携网络图形(PNG)格式可能更容易。

要获得其中一个类的画笔,只需创建类的一个实例,然后传递一个指向QPainter构造函数实例的指针。 例如,要执行上一节中对屏幕外图像的绘制并将其保存为 PNG 文件,我们将从编写以下代码开始:

QImage image(100, 100, QImage::Format_ARGB32); 
QPainter painter(&image); 

第一行创建一个 100 像素正方形的图像,将每个像素编码为 32 位整数,红色、绿色和蓝色的每个不透明通道对应 8 位。 第二行创建一个可以在QImage实例上绘制的QPainter实例。 接下来,我们执行您在上一节中刚刚看到的绘图,完成后,我们将图像写入 PNG 文件,并显示以下行:

image.save("face.png"); 

QImage支持多种图像格式,包括 PNG 和 JPEG。 QImage还有一个load方法,可以从文件或资源加载图像。

就是这样,我们不仅学习了如何在屏幕上绘制图像,还学习了如何将其绘制出屏幕并将其保存到图像文件中。 接下来,我们将继续学习如何在 Qt 中创建我们自己的自定义小部件。

创建自定义小部件

本质上,使用自定义小部件绘制与屏幕外绘制没有什么不同;您只需要一个小部件子类和一个指向小部件的绘图器,就可以了。 然而,你怎么知道什么时候该画呢?

Qt 的QWidget类定义了呈现系统用来将事件传递给小部件的接口:Qt 定义了QEvent类来封装有关事件的数据,而QWidget类定义了一个接口,Qt 的呈现系统使用该接口将事件传递给小部件进行处理。 Qt 不仅使用此事件系统来指示鼠标移动和键盘输入等内容,还使用它来请求绘制屏幕。

我们先来看一下绘画。 QWidget 定义了paintEvent方法,Qt 的呈现系统通过传递QPaintEvent指针来调用该方法。 QPaintEvent指针包括需要重新绘制的区域和该区域的边界矩形,因为重新绘制整个矩形通常比重新绘制复杂区域更快。 当您使用QPainter绘制小部件的内容时,Qt 会对该区域执行必要的裁剪;但是,如果有用的话,您可以使用该信息作为需要重绘内容的提示。

让我们看另一个绘画示例,这一次是一个模拟时钟小部件。 此示例来自 Qt 附带的示例代码;您可以在https://doc.qt.io/qt-5/qtwidgets-widgets-analogclock-example.html中看到它。

我在这里包含了实现模拟时钟的整个QWidget子类。 我们将把它分成几部分;首先是必须包含的标题,如下所示:

#include "analogclock.h" 

构造函数位于标头包含之后,如下所示:

AnalogClock::AnalogClock(QWidget *parent) : QWidget(parent) 
{ 
    QTimer *timer = new QTimer(this); 
    connect(timer, &QTimer::timeout, this, &AnalogClock::update); 
    timer->start(1000); 
    resize(200, 200); 
} 

构造函数创建一个timer对象,该对象每隔1000毫秒发出一个超时信号,并将该计时器连接到小部件的update槽。 update插槽强制小部件重新绘制;这就是小部件每秒更新自身的方式。 最后,它将小部件本身的大小调整为边上的200个像素。

timeout信号触发update槽功能,基本上告知父QWidget类刷新屏幕,随后触发paintEvent功能,如下所示:

void AnalogClock::update()
{
    QWidget::update();
}

下一部分是 Paint 事件处理程序。 这是一个很长的方法,所以我们将把它分成几个部分来看。 该方法可以在以下代码块中看到:

void AnalogClock::paintEvent(QPaintEvent *) 
{ 
    static const QPoint hourHand[3] = { 
        QPoint(7, 8), 
        QPoint(-7, 8), 
        QPoint(0, -40) 
    }; 
    static const QPoint minuteHand[3] = { 
        QPoint(7, 8), 
        QPoint(-7, 8), 
        QPoint(0, -70) 
    }; 

    QColor hourColor(127, 0, 127); 
    QColor minuteColor(0, 127, 127, 191); 
    int side = qMin(width(), height()); 
    QTime time = QTime::currentTime(); 

    QPainter painter(this); 

在此之前是堆栈变量的声明,包括时针和分针的坐标数组和颜色,并获取用于绘制的QPainter实例。

接下来是设置绘图器本身的代码。 我们请求一个抗锯齿图形,并使用 Qt 的支持来缩放和平移视图,以使我们的坐标计算更加简单,如下所示:

    painter.setRenderHint(QPainter::Antialiasing); 
    painter.translate(width() / 2, height() / 2); 
    painter.scale(side / 200.0, side / 200.0); 

    painter.setPen(Qt::NoPen); 
    painter.setBrush(hourColor); 

我们将原点平移到小部件的中间。 最后,我们设置钢笔和画笔;我们为钢笔选择NoPen,因此绘制的只有填充,并且我们最初将画笔设置为小时画笔颜色。

在那之后,我们画时针。 此代码在渲染中使用 Qt 对旋转的支持,将视口旋转适当的量以放置时针(每小时需要 30 度),并为指针本身绘制一个凸多边形。 下面的代码片段显示了这一点:

    painter.save(); 
    painter.rotate(30.0 * ((time.hour() + time.minute() / 60.0))); 
    painter.drawConvexPolygon(hourHand, 3); 
    painter.restore(); 

代码在旋转之前保存画笔的配置状态,然后在绘制时针之后恢复(未旋转)状态。

当然,时针最好带有小时标记,所以我们循环 12 圈,为每个小时标记画一条线,如下所示:

    painter.setPen(hourColor); 

    for (int i = 0; i < 12; ++ i) { 
        painter.drawLine(88, 0, 96, 0); 
        painter.rotate(30.0); 
    } 

时针不挡道了,现在是画分针的时候了。 我们使用相同的旋转技巧将分针旋转到正确的位置,为分针绘制另一个凸多边形,如下所示:

    painter.setPen(Qt::NoPen); 
    painter.setBrush(minuteColor); 
    painter.save(); 
    painter.rotate(6.0 * (time.minute() + time.second() / 60.0)); 
    painter.drawConvexPolygon(minuteHand, 3); 
    painter.restore(); 

最后,我们在钟面周围画 60 个刻度线,每分钟一个刻度线,如下所示:

    painter.setPen(minuteColor); 

    for (int j = 0; j < 60; ++ j) { 
        if ((j % 5) != 0) 
            painter.drawLine(92, 0, 96, 0); 
        painter.rotate(6.0); 
    } 
}

正如我前面所暗示的,自定义小部件也可以接受事件;mousePressEventmouseReleaseEventmouseDoubleClick事件指示用户在小部件边界内按下、释放或双击鼠标的时间。 还有mouseMoveEventmouseMoveEvent,每当鼠标在小部件中移动并按下鼠标按钮时,Qt 系统都会调用它。 该界面还指定了按键事件:有告诉您用户何时按下某个键的keyPressEvent,以及分别指示小部件何时获得和失去键盘焦点的focusInEventfocusOut事件。

头文件看起来要简单得多,我们可以在以下代码块中看到:

#include <QWidget>
#include <QTimer>
#include <QTime>
#include <QPainter>

class AnalogClock : public QWidget
{
    Q_OBJECT
public:
    explicit AnalogClock(QWidget *parent = nullptr);
    void paintEvent(QPaintEvent *);
signals:
public slots:
    void update();
};

下面的屏幕截图显示了运行中的钟面:

For more information about the QWidget interface and creating custom widgets, see the QWidget documentation at https://doc.qt.io/qt-5/qwidget.html and the Qt event system documentation at https://doc.qt.io/qt-5/eventsandfilters.html.

在本节中,我们学习了如何使用 Qt 的 Paint 事件创建实时模拟时钟显示。 让我们继续下一节,学习如何使用 Qt 的 Graphics View 框架创建一个简单的 2D 游戏!

图形视图框架简介

Qt 提供了一个单独的视图框架,即 Graphics View 框架,可以一次绘制成百上千个相对轻量级的自定义项目。 如果您正在从头开始实现您自己的小部件集(尽管您可能也想考虑 Qt Quick 来实现这一点),或者如果您有大量的项目要同时显示在屏幕上,每个项目都有自己的位置和数据,那么您可以选择 Graphics View 框架。 这对于处理和显示大量数据的应用尤其重要,例如地理信息系统或计算机辅助设计应用。

在 Graphics View 框架中,Qt 定义场景,负责为大量项目提供快速界面。 (如果您还记得我们在上一章中对Model-View-Controller(MVC)的讨论,您可以将场景视为视图渲染器的模型。)。 该场景还将事件分布到它包含的项目,并管理场景中各个项目的状态。 QGraphicsScene是负责场景实现的 Qt 类。 您可以将QGraphicsScene看作一个可绘制项目的容器,每个项目都是QGraphicsItem的子类。

您的QGraphicsItem子类可用于覆盖每个项目的绘图和事件处理,然后您可以通过调用addItem方法QGraphicsScene将您的自定义项目添加到您的QGraphicsScene类中。 QGraphicsScene提供了一个items方法,该方法返回点、矩形、多边形或常规矢量路径包含或相交的项的集合。 在幕后,QGraphicsScene使用二进制空间分区树(参见 Wikipedia 在http://en.wikipedia.org/wiki/Binary_space_partitioning上关于 BSP 树的文章),以便非常快速地按位置搜索条目层次结构(请参阅 Wikipedia 上关于 BSP 树的文章)。

场景中有一个或多个QGraphicsItem子类实例,表示场景中的图形项;Qt 定义了一些用于渲染的简单子类,但您可能需要创建自己的子类。 Qt 提供以下功能:

  • QGraphicsRectItem:这用于呈现矩形。
  • QGraphicsEllipseItem:这是用来渲染椭圆的。
  • QGraphicsTextItem:这用于呈现文本。

让我们在这里逐一了解一下:

QGraphicsItem提供一个可以在子类中重写的接口,用于管理鼠标和键盘事件、拖放、接口层次结构和冲突检测。 每个项目都驻留在其自己的局部坐标系中,辅助函数为您提供了项目坐标和场景坐标之间的快速转换。

图形视图框架使用一个或多个QGraphicsView实例来显示QGraphicsScene类的内容。 可以将多个视图附加到同一场景,每个视图都有自己的平移和旋转,以查看场景的不同部分。 QGraphicsView小部件是一个滚动区域,因此您还可以将滚动条挂接到视图,让用户在视图中滚动。 视图接收来自键盘和鼠标的输入,为场景生成场景事件,并将这些场景事件调度到场景,然后场景将这些相同的事件调度到场景中的项目。

Graphics View 框架非常适合于创建游戏,事实上,Qt 的示例源代码就是您可以在https://wiki.qt.io/Towers_lasers_and_spacecrafts_example上看到的塔楼和宇宙飞船示例应用。 如果你愿意的话,这个游戏很简单,由电脑来玩;静止的塔楼射击迎面而来的移动的宇宙飞船,正如你在下面的屏幕截图中所看到的:

让我们看一下这个示例应用中的部分代码,以了解 Graphics View 框架的实际工作方式。

游戏的核心是更新移动设备位置的游戏计时器;应用的入口点设置计时器QGraphicsView和负责跟踪状态的QGraphicsScene的子类。 为此,请考虑以下代码:

#include "mainwindow.h"

#include <QApplication>
#include <QGraphicsView>
#include "scene.h"
#include "simpletower.h"

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    Scene scene;
    scene.setSceneRect(0,0,640,360);
    QGraphicsView view(&scene);
    QTimer timer;
    QObject::connect(&timer, &QTimer::timeout, &scene, 
      &Scene::advance);
    view.show();
    timer.start(10);
    return app.exec();
}

计时器每 10 毫秒计时一次,并连接到场景的提前时段,负责推进游戏状态。 QGraphicsView类是整个场景的渲染窗口;它接受要从中渲染的Scene对象的一个实例。 应用的main函数初始化视图、场景和计时器,启动计时器,然后将控制传递给 Qt 的事件循环。

Scene类有两个方法:一个是构造函数,它在场景中创建一些不移动的塔;另一个是advance方法,它推进场景的一次性计时,每次经过main函数中的计时器时都会触发该方法。 让我们首先看一下构造函数,如下所示:

#include "scene.h"
#include "mobileunit.h"
#include "simpletower.h"
#include <QDebug>

Scene::Scene(): QGraphicsScene(), ticTacTime(0)
{
    SimpleTower * simpleTower = new SimpleTower();
    simpleTower->setPos(200.0, 100.0);
    addItem(simpleTower);

    simpleTower = new SimpleTower();
    simpleTower->setPos(200.0, 180.0);
    addItem(simpleTower);

    simpleTower = new SimpleTower();
    simpleTower->setPos(200.0, 260.0);
    addItem(simpleTower);

    simpleTower = new SimpleTower();
    simpleTower->setPos(250.0, 050.0);
    addItem(simpleTower);

我们继续创建更多的SimpleTower对象,并使用setPosaddItem对其进行初始化,如下所示:

    simpleTower = new SimpleTower();
    simpleTower->setPos(250.0, 310.0);
    addItem(simpleTower);

    simpleTower = new SimpleTower();
    simpleTower->setPos(300.0, 110.0);
    addItem(simpleTower);

    simpleTower = new SimpleTower();
    simpleTower->setPos(300.0, 250.0);
    addItem(simpleTower);

    simpleTower = new SimpleTower();
    simpleTower->setPos(350.0, 180.0);
    addItem(simpleTower);
}

非常无聊-它只创建静态塔的实例并设置它们的位置,使用addItem方法将每个塔添加到场景中。 在查看SimpleTower类之前,让我们先看一下Scene类的advance方法,如以下代码块所示:

void Scene::advance()
{
    ticTacTime++ ;

    // delete killed objects
    QGraphicsItem* item = nullptr;
    MobileUnit* unit = nullptr;
    int i = 0;

    while (i < items().count())
    {
        item = items().at(i);
        unit = dynamic_cast<MobileUnit*>(item);
        if ((unit != nullptr) && (unit->getIsFinished() == true))
        {
            removeItem(item);
            delete unit;
        }
        else
            ++ i;
    }

在此之后,我们每隔 20 个刻度添加一个新单位,如下所示:

    // Add new units every 20 tictacs
    if (ticTacTime % 20 == 0)
    {
        // qDebug() << "add unit";
        MobileUnit* mobileUnit= new MobileUnit();
        qreal h = static_cast<qreal>(qrand() % static_cast<int>
          (height()));
        mobileUnit->setPos(width(), h);
        addItem(mobileUnit);
    }

    QGraphicsScene::advance();
    update();
}

从前面的代码中,我们可以看到该方法有两个关键部分,如下所述:

  • 第一部分删除由于某种原因(例如,它们的健康状况降至 10)而过期的所有移动单元。 这是通过循环遍历场景中的所有项目并测试每个项目是否都是MobileUnit实例来实现的。 如果是,则代码测试其isFinished函数,如果为真,则从场景中删除该项目并释放它。
  • 第二部分每隔 20 次通过advance方法运行一次,并创建一个新的MobileUnit对象,将其随机放置在显示屏的右侧。 最后,该方法调用继承的advance方法,该方法触发对场景中每个项目的提前调用,然后调用update,这触发场景的重绘。

接下来让我们看一下SimpleTowerQGraphicsItem子类。 首先,让我们看一下SimpleTower构造函数,如以下代码块所示:

#include <QPainter>
#include <QGraphicsScene>
#include "simpletower.h"
#include "mobileunit.h"

SimpleTower::SimpleTower(): QGraphicsRectItem()
, detectionDistance(100.0), time(0, 0)
, reloadTime(100), shootIsActive(false)
, target(nullptr), towerImage(QImage(":/lightTower.png"))
{
    setRect(-15.0, -15.0, 30.0, 30.0);
    time.start();
}

构造函数设置塔楼的边界并启动计时器,用于确定塔楼向迎面而来的船只开火的时间间隔。

QgraphicsItem实例在其paint方法中进行绘制;paint方法采用将用于呈现项的QPainter指针,以及指向项和层次结构中所属小部件的呈现选项的指针。 下面是SimpleTowerpaint方法:

void SimpleTower::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget* widget)
{
    painter->drawImage(-15, -15, towerImage);
    if ((target != nullptr) && (shootIsActive))
    { // laser beam
        QPointF towerPoint = mapFromScene(pos());
        QPointF theTarget = mapFromScene(target->pos());
        painter->setPen(QPen(Qt::yellow,8.0,Qt::SolidLine));
        painter->drawLine(towerPoint.x(), towerPoint.y(), 
          theTarget.x(), theTarget.y());
        painter->setPen(QPen(Qt::red,5.0,Qt::SolidLine));
        painter->drawLine(towerPoint.x(), towerPoint.y(), 
          theTarget.x(), theTarget.y());
        painter->setPen(QPen(Qt::white,2.0,Qt::SolidLine));
        painter->drawLine(towerPoint.x(), towerPoint.y(), 
          theTarget.x(), theTarget.y());
        shootIsActive = false;
    }
}

paint方法必须绘制两个内容:塔本身,这是在构建时加载的静态图像(用drawImage绘制),如果塔向目标射击,它会在塔和塔所针对的移动单元之间绘制彩色线条。

接下来,我们将继续学习advance方法,如以下代码块所示:

void SimpleTower::advance(int phase)
{
    if (phase == 0)
    {
        searchTarget();
        if ((target != nullptr) && (time.elapsed() > reloadTime))
            shoot();
    }
}

每次场景前进时,每个塔都会搜索一个目标,如果选择了一个,它就会向目标射击。 场景图为每次前进调用每个项目的advance方法两次,传递一个整数,指示场景中的项目是即将前进(当phase参数为0时表示),还是场景中的项目已经前进(当phase段为1时表示)。

searchTarget方法在检测距离内查找最近的目标,如果找到,则将塔的目标指针设置为范围内最近的单位,如下所示:

void SimpleTower::searchTarget()
{
    target = nullptr;
    QList<QGraphicsItem*> itemList = scene()->items();
    int i = itemList.count() - 1;
    qreal dx, dy, sqrDist;
    qreal sqrDetectionDist = detectionDistance * detectionDistance;
    MobileUnit* unit = nullptr;
    while((i >= 0) && (nullptr == target) )
    {
        QGraphicsItem * item = itemList.at(i);
        unit = dynamic_cast<MobileUnit*>(item);
        if ((unit != nullptr) && (unit->getLifePoints() > 0))
        {
            dx = unit->x() - x();
            dy = unit->y() - y();
            sqrDist = dx * dx + dy * dy;
            if (sqrDist < sqrDetectionDist)
                target=unit;
        }
        --i;
    }
}

请注意,我们缓存指向目标单元的指针并调整其位置,因为在后续帧中,目标单元将移动。 最后,shoot方法简单地设置了paint用来指示应该绘制拍摄图形的布尔标志,它向目标指示它已被损坏。 下面的代码显示了这一点:

void SimpleTower::shoot()
{
    shootIsActive=true;
    target->touched(3);
    time.restart();
}

这将重新启动计时器,该计时器用于跟踪计时器拍摄的后续快照之间的时间。 最后,让我们看一下在场景中渲染单个移动宇宙飞船的MobileUnit类。 遵循以下步骤:

  1. 首先定义include指令,然后定义构造函数,如下所示:
#include "mobileunit.h"
#include <QPainter>
#include <QGraphicsScene>
#include <math.h>

MobileUnit::MobileUnit(): QGraphicsRectItem()
, lifePoints(10), alpha(0)
, dirX(1.0), dirY(0.0)
, speed(1.0), isFinished(false)
, isExploding(false), explosionDuration(500)
, redExplosion(0.0, 0.0, 20.0, 0.0, 0.0), time(0, 0)
, spacecraftImage(QImage(":/spacecraft00.png") )
{
    alpha = static_cast<qreal>(qrand() % 90 + 60);
    qreal speed = static_cast<qreal>(qrand()% 10 - 5);
    dirY = cos(alpha / 180.0 * M_PI );
    dirX = sin(alpha / 180.0 * M_PI);
    alpha = -alpha * 180.0 ;
    speed = 1.0 + speed * 0.1;
    setRect(-10.0, -10.0, 20.0, 20.0);
    time.start();

    redExplosion.setColorAt(0.0, Qt::white);
    redExplosion.setColorAt(0.2, QColor(255, 255, 100, 255));
    redExplosion.setColorAt(0.4, QColor(255, 80, 0, 200));
    redExplosion.setColorAt(1.0, QColor(255, 255, 255, 0));
}

构造器比固定单元的构造器稍微复杂一些。 它需要为移动单元设置初始航向和速度。 然后,它设置单元和计时器的界限来控制自己的行为。 如果该单元被禁用,它将爆炸;我们将使用径向渐变中的同心圆绘制爆炸,因此我们需要在渐变中的各个点设置颜色。

  1. 接下来是paint方法,该方法在单位受损时绘制单位或单位爆炸,如以下代码块所示:
void MobileUnit::paint(QPainter *painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
{
    painter->setPen(Qt::NoPen);

    if (!isExploding)
    {
        painter->rotate(alpha);
        painter->drawImage(-15, -14, spacecraftImage);
    }
    else
    {
        painter->setBrush(QBrush(redExplosion));
        qreal explosionRadius = 8.0 + time.elapsed() / 50;
        painter->drawEllipse(-explosionRadius, -explosionRadius, 2.0 * explosionRadius, 2.0 * explosionRadius);
    }
}

这非常简单:如果单元没有爆炸,它只设置要绘制的图像的旋转并绘制图像;否则,它使用我们在构造函数中配置的径向渐变笔刷绘制圆形爆炸。

  1. 之后是advance方法,该方法负责将舰船从一个帧移动到下一个帧,并跟踪爆炸舰船的状态,如以下代码块所示:
void MobileUnit::advance(int phase)
{
    if (phase==0)
    {
        qreal xx = x(); qreal yy = y();
        if ( (xx < 0.0) || (xx > scene()->width() ) )
        { // rebond
            dirX = -dirX;
            alpha = -alpha;
        }
        if ( (yy < 0.0) || (yy > scene()->height()))
        { // rebond
            dirY = -dirY;
            alpha = 180 - alpha;
        }
        if (isExploding)
        {
            speed *= 0.98; // decrease speed
            if (time.elapsed() > explosionDuration)
                isFinished = true; // is dead
        }
        setPos(x() + dirX * speed, y() + dirY * speed);
    }
}

为简单起见,advance方法通过反转方向和方向使场景边缘的项目从页边距反弹。 如果物品正在爆炸,则其减速,并且如果定时器中经过的时间长于爆炸持续时间,则该方法设置指示在下一场景推进期间应当从场景中移除该物品的标志。 最后,该方法通过将方向和速度的乘积与每个坐标相加来更新项目的位置。

  1. 最后,touched方法将移动单元的健康点递减指定的量,如以下代码块所示:
void MobileUnit::touched (int hurtPoints)
{
    lifePoints -= hurtPoints; // decrease life
    if (lifePoints < 0)
        lifePoints = 0;
    if (lifePoints == 0)
    {
        time.start();
        isExploding = true;
    }
}

如果该装置的生命值为零,它会启动爆炸定时器并设置爆炸标志。

For more documentation about the Graphics View framework, see the Qt documentation at https://doc.qt.io/qt-5/graphicsview.html.

就是这样,我们已经成功地使用 Qt 的 Graphics View 框架创建了一个简单的游戏。 你可以进一步扩展这个项目,把它变成一个完整的游戏,也许还可以在 App Store 上发布它!

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,我们学习了如何使用QPainter类在屏幕上和屏幕外绘制图形。 我们还学习了如何在 Qt 中创建自己的自定义小部件。 然后,我们探索了 Graphics View 框架并创建了一个简单的游戏。

贯穿本章,我们了解了 Qt 如何提供QPaintDevice接口和QPainter类来执行图形操作。 使用QPaintDevice子类(如QWidgetQImageQPixmap),您可以执行屏幕上和屏幕外绘制。 我们还了解了 Qt 如何通过 Graphics View 框架(由类QGraphicsViewQGraphicsScene以及QGraphicsItem支持)为大量轻量级对象提供单独的可视对象层次结构。

在下一章中,我们将从 Qt 对 C++ 中 GUI 的支持转向 Qt Quick 的支持。 我们将学习基本的 Qt Quick 构造、在 Qt Quick 中执行动画和其他过渡,以及如何将 Qt Quick 与 C++ 应用集成。