工程效果
具体完整代码可在示例里面找到。工程路径在Qt安装目录下的
Examples\Qt-XX.XX.XX\widgets\graphicsview\collidingmice 目录下,XX.XX.XX为Qt的版本号,如:5.14.1。
可以看到,小老鼠碰撞后耳朵会变红。
工程总体就是多了一个mouse的源文件和头文件,即Mouse类相关文件。在Graphics View框架结构主要包含三个类:场景类(QGraphicsScene)、视图类(QGraphicsView)和图元类(QGraphicsItem),统称为“三要素”。而Mouse类就是继承自QGraphicsItem类的自定义类。
Scene类管理数量众多的Item类,并组织起他们的信息交流。但是它只是起后台管理的功能,并不会让这些Item类展现出现。View类起到将Item类可视化的功能,支持旋转和缩放。有点类似于浏览器的后端与前端,后端负责流程交互,前端负责展示效果。
mouse.h
其实QT本身就提供了Item类,包括矩形、椭圆形、线型等,但是我们一般使用的图案都是几种类型的结合,所以需要自定义类。以下为官方提供的Item类。
class Mouse : public QGraphicsItem
{
public:
Mouse();
QRectF boundingRect() const override;
QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option,
QWidget *widget) override;
protected:
void advance(int step) override;
private:
qreal angle = 0;
qreal speed = 0;
qreal mouseEyeDirection = 0;
QColor color;
};
Mouse类是继承自QGraphicsItem类,所以我们在工程里面见到很多并未定义的函数,就是来自QGraphicsItem类。在Graphics View框架当中,QGraphicsItem是所有item的基础类。后三个函数是自定义Item类必须重新定义的纯虚函数。
paint函数是实际的绘制函数,包含老鼠的身体、眼镜等部分都是需要在paint函数里面绘制。
boundingRect 函数返回的是Item重新绘制的区域大小,相当于老鼠的平面面积。因为实际上动画的实现,就是擦除旧的,再画新的,所以好像移动了一样。这个擦除区域的大小就是由boundingRect() 来返回的。
Shape函数主要用于碰撞检测,它会返回老鼠的精确形状,默认情况下它返回的就是boundingRect的值,但有时候我们想要靠近5cm就认为碰撞,就需要重写该函数。
advance函数用来推进Scene,可以很容易实现Item的动画效果。调用Scene的advance函数就会自动调用Scene中所有Item的advance函数,而且Item的advane函数会被分为两个阶段调用两次。第一次phase为0,告诉所有的Items:Scene将要改变;第二次phase为1,在这时才进行具体的操作。
qreal是double数据类型,QColor是Qt的颜色变量,这几个定义的向量在后面会用到。
mouse.cpp
一、paint函数
先来看看它是怎么通过paint函数画出老鼠的。Graphics View框架调用paint函数绘制item的内容,而且是以本地坐标系为基准。
1.身体、眼睛、鼻子
setBrush函数是设置笔刷的函数,主要就是内部填充的颜色,这里的color变量是Mouse类的私有变量,主要是想实现随机颜色的老鼠。
drawEllipse函数是paninter类的画椭圆的函数,共有四个参数,前两个代表对应矩形的左上角的坐标,后两个代表宽度和高度。
setBrush函数是设置笔刷的函数,主要就是内部填充的颜色,这里的color变量是Mouse类的私有变量,主要是想实现随机颜色的老鼠。
drawEllipse函数是paninter类的画椭圆的函数,共有四个参数,前两个代表对应矩形的左上角的坐标,后两个代表宽度和高度。
随机颜色的设定:color是Qt类Qcolor的实例化对象,三个参数代表RGB的值。bounded函数主要用于产生0-256的随机数。帮助手册说明如下:
随机角度的确定:setRotation函数是继承自QGraphicsItem类的一个函数,主要用于设定item出现在sence坐标系时的角度。默认情况下,item一般是以sence的中心(0,0)为自身局部坐标系的中心。通过setRotation函数可以改变老鼠开始移动时的方向。
接下来画白色的眼睛和黑色的鼻子,如果drawEllipse的后两个参数是相同的,画出来就是圆形。至于这个QRectF是强制转换函数,就是将里面四个参数先转为QRectF,然后再作为drawEllipse函数的参数。这个叫函数的重载。不管是直接四个参数,还是传入QRectF参数都是可以的,QT都有提供对应的函数。
painter->setBrush(Qt::black);
painter->drawEllipse(QRectF(-2, -22, 4, 4));
2.瞳孔
这里存在一个变量就是矩形左上角的x,这个mouseEyeDirection变量是在advance函数里面进行更新的,主要实现的效果就是,当老鼠往左,那瞳孔就左移一点;当老鼠往右,那瞳孔就右移一点。
3.耳朵
耳朵是用来表示碰撞的,正常情况下耳朵是灰黄色,当老鼠之间发生碰撞,耳朵会变成红色。这里使用了scene类的collidingItems函数进行判断是否发生碰撞。这个this代表的就是类本身,就是某一个对象(老鼠),函数会返回与该老鼠碰撞的其他item的清单。因为此工程只需要判断碰撞与否,而不需要判断与谁发生碰撞,所以用isEmpty函数判断是否为空就行了。
使用手册相关说明如下:
从By default, all items whose shape intersects item or is contained inside item's shape are returned.可以得知,碰撞的检测是以shape交叉或者包含为准的,这就是shape函数实现的。
4.尾巴
drawPath函数用于绘制给定的路径曲线。
cubicTo函数是功能是画贝塞尔曲线的。使用c1和c2指定的控制点在当前位置和给定端点之间添加三次Bezier曲线。添加曲线后,将当前位置更新为曲线的端点。所以例程中存在6个参数,其实对应的就是c1、c2、endPoint三个点的坐标点。而实例化对象时用的(0,20)就是起点。
因为path.cubicTo(-5, 22, -5, 22, 0, 25);中的c1和c2是一样的,相当于曲线一定要从这一点经过(贝塞尔曲线的一个特性是:控制点之间的连线必定和曲线相交,而这两个点坐标相同,在这两个控制点就是交点,即在曲线上)。而path.cubicTo(5, 27, 5, 32, 0, 30);则表示曲线得从(5,27)和(5,32)这两个点中间经过,然后到达(0,30)这个点。
至此,老鼠已经画完了。关于贝塞尔曲线的简单介绍,请参见《贝塞尔曲线简单介绍》。
二、advance函数
当Scene准备刷新场景的时候,就会调用每一个Item类的advance函数,完成场景的刷新。通过advance函数我们可以完成老鼠的移动。
void Mouse::advance(int step)
{
if (!step)
return;
QLineF lineToCenter(QPointF(0, 0), mapFromScene(0, 0));
if (lineToCenter.length() > 150) {
qreal angleToCenter = std::atan2(lineToCenter.dy(), lineToCenter.dx());
angleToCenter = normalizeAngle((Pi - angleToCenter) + Pi / 2);
if (angleToCenter < Pi && angleToCenter > Pi / 4) {
// Rotate left
angle += (angle < -Pi / 2) ? 0.25 : -0.25;
} else if (angleToCenter >= Pi && angleToCenter < (Pi + Pi / 2 + Pi / 4)) {
// Rotate right
angle += (angle < Pi / 2) ? 0.25 : -0.25;
}
} else if (::sin(angle) < 0) {
angle += 0.25;
} else if (::sin(angle) > 0) {
angle -= 0.25;
}
首先,如果step为0,那老鼠不做任何的前进,直接return。因为scene会调用两次advance函数,第一次调用step为0,表明item即将更新;第二次调用step等于1,这次是实际的更新。所以我们要保证step等于0时,item不发生更改。
GraphicsView坐标系统
Item坐标系是自身的局部坐标系,Scene坐标系是全局坐标系。之前我们通过paint画图就是在Item类坐标系上画的,但老鼠的移动时是需要在Scene类坐标系中移动的,所以存在一个坐标映射的问题,也可以称之为坐标转化。比如图中Item类坐标系的原点(0,0)转换为Scene类坐标系就是大概为(15,-10)这样。
QLineF lineToCenter(QPointF(0, 0), mapFromScene(0, 0));
QLineF是表示一条线,两个参数,一个起点和一个终点。mapFromScene函数是继承自QGraphicsItem类的函数,主要是将Scene的坐标转换为Item类的坐标。这句代码其实就是将Scene类坐标(0,0)转换为Item坐标系中的点。这条线就是如下图所示的红色直线。
如果这条直线的长度length大于150,就进行方向变化,这是为了保证老鼠待在一个150像素的圆内。如果超过150,就掉头。通过QLineF类的dx函数和dy函数,结合atan2函数可以算出这条直线的角度,公式为float angle = atan2( y2-y1, x2-x1 ),即终点减去起点。atan2函数与atan函数的取值范围不同,是-π到π。如果对atan、atan2不懂,请参考
《C++中反正切atan2(y,x)与atan(x)的区别》博文。
qreal angleToCenter = std::atan2(lineToCenter.dy(), lineToCenter.dx());
angleToCenter = normalizeAngle((Pi - angleToCenter) + Pi / 2);
因为atan2的范围为[-PI, PI], 运用数学不等式知识,则:
-PI<= - angleToCenter <= PI
0 <= PI - angleToCenter <= 2*PI
PI / 2 <= PI - angleToCenter + PI / 2 <= 2*PI + PI / 2
即从Y轴正方向绕了360°即转了一个圆后又回到Y轴正方向。例程中通过atan2函数计算得出方位角后,还经过了normalizeAngle函数去调整角度,而这个normalizeAngle函数是例程自定义的角度处理函数。函数功能不难理解,就是将PI /2 ~ 5* PI / 2角度范围限定在0-2π之间。
static qreal normalizeAngle(qreal angle)
{
while (angle < 0)
angle += TwoPi;
while (angle > TwoPi)
angle -= TwoPi;
return angle;
}
为什么通过atan2角度之后,还需要经过(Pi - angleToCenter) + Pi / 2计算呢?这是我思考很久才弄明白的,因为说明手册也没说。
以下图为例,角度不代表真实角度,就是为了表示他们之间的换算关系。老鼠的头部是朝上的,我们习惯以老鼠头部为基准,然后去判断方向,如下图我们可以认为Scene坐标系原点在老鼠头部的左前方。
但是tan2函数算出来的角度是以Item坐标系为准的,比如下图的-120°就是通过atan2函数计算得出,这代表直线角度从x正方向逆时针转动了120°。通过(Pi - angleToCenter) + Pi / 2计算,得到的角度是390°,以这个参数代入normalizeAngle函数,得到30°,这个就是我们想要的角度。
所以我们可以推断出这个angleToCenter角度是以Item坐标系的y轴负方向为基准,然后逆时针旋转的角度。
再看一个例子:
当然在程序中得到的数值和计算的数值都是以弧度制表示的,上面为了讲解用了角度值。原理是一样的。
如果angleToCenter处于左下角区域,老鼠可能往左调头返回,也可能往右调头返回,这个由angle决定。angleToCenter表示的是Scenne坐标系原点和Item坐标系原点的角度,即确定老鼠中心所处的位置,那angle又是表示什么?
angle能推测出是老鼠前进的方向,通过说明手册能得知,这个角度是Item沿Z轴旋转的方向,以顺时针为正,默认情况下为0.也就是两个坐标系x、y轴方向一致的时候是0.
下面这张图,此时老鼠的angle就是0度,没有发生旋转。
根据angleToCenter值不同,angle增减值也不一样。弧度制0.25大约相当于角度制是14度。
if (angleToCenter < Pi && angleToCenter > Pi / 4) {
// Rotate left
angle += (angle < -Pi / 2) ? 0.25 : -0.25;
下图以箭头方向表示老鼠方向,判断标准是-Pi/2,即原始老鼠沿逆时针旋转90°。通过下图可以看到,这个判断就是:让老鼠往-Pi/2方向偏转,调头往Scene坐标系中心移动,不至于跑出圈外。其实现原理是:
- 假如老鼠在(pi /4, pi)区间内(见上上一副图所示)跑动时,逆时针转动的跨角小于pi / 2, 则补偿即加上0.25弧度的角度,防止老鼠跑动时,向左上角跑出了圈外。
- 假如老鼠在(pi /4, pi)区间内(见上上一副图所示)跑动时,逆时针转动的跨角大于pi / 2, 则补偿即减去0.25弧度的角度,防止老鼠跑动时,向右下角跑出了圈外。
- 假如老鼠在[pi, Pi + Pi / 2 + Pi / 4)区间内(见上上一副图所示)跑动时,顺时针转动的跨角大于pi / 2, 则补偿即减去0.25弧度的角度,防止老鼠跑动时,向右上角跑出了圈外。
- 假如老鼠在[pi, Pi + Pi / 2 + Pi / 4)区间内(见上上一副图所示)跑动时,顺时针转动的跨角小于pi / 2, 则补偿即加上0.25弧度的角度,防止老鼠跑动时,向左下角跑出了圈外。
下面几个判断的原理差不多,都是让不同位置的老鼠掉头往中心移动。
避免碰撞
上面这一段代码是为了尝试不让老鼠撞在一起。QList是QT的链表数据结构,类似于数组。这个列表里面的数据类型是Item类指针,就是那一只只小老鼠。scene函数继承于Item类,返回这个Item目前所处的scene。items函数是QGraphicsScene类的函数,返回该scene中所有的Item的顺序列表。
mapToScene函数是把Item坐标系的点映射到Scene坐标系,是mapFromScene函数的反操作。
QPoltgonF函数是生成多边形的函数,以三个点生成多边形,就是三角形。(0,0)是老鼠的中心位置,(-30,-50)和(30,-50)是老鼠的两个耳朵左上角和右上角边缘。这三个点生成的三角形,相当于半个老鼠大小的三角形。
计算得到angleToMouse和angleToCenter是一样的,都是以y负半轴为0°,逆时针旋转。angle是以z轴顺时针旋转的角度,加上0.5就是顺时针旋转大约28°,减去0.5就是逆时针旋转大约28°。
如下图所示,蓝色老鼠就位于橙色老鼠0-Pi/2的区域内,根据判断条件,增加angle增加0.5,向右旋转28°,避开蓝色老鼠。
增加随机运动
dangerMice就是那个装着全部老鼠的队列,如果队列里面的老鼠数量size大于1,且产生的随机数等于0。因为bounded产生的随机数位于0-9之间,所以等于0的概率是十分之一。后面又增加或者减少一个随机的角度。
小鼠移动
角度angle由三个方式确定,一是与中心点的距离和位置,二是与其他老鼠的位置关系,三是随机运动产生的角度。speed也是一个由bounded产生的随机数,结合整条式子,产生的speed值为-0.05至+0.49。dx范围为-10至10之间。
rotation函数表示的是当前item绕Z轴顺时针旋转的角度,默认值是0(如下图所示)。
setRotation设置的就是Item的旋转角度,范围是-360°至360°。setRotation(rotation() + dx);就是在原来的基础上,旋转dx度。但是例程通过qreal dx = ::sin(angle) * 10;把弧度制的angle转换成了角度值dx,这个转换关系我也没搞懂,可能就是大致范围的转换吧。因为正常来说弧度制转角度值,是/Pi*180。
setRotation决定了老鼠的方向,而setPos就是让老鼠运动起来。根据说明手册,设定item在父坐标系中的位置,如果它没有父类,那就以scene坐标系为准。这个例程的老鼠就是单独的item,它没有父类。 item的位置描述,指的是item坐标系的原点在scene坐标系的位置。
setPos(mapToParent(0, -(3 + sin(speed) * 3)));
boundingRect函数
boundingRect函数以一个矩形定义了item的外部界限。Graphice View框架使用这个矩形来决定item是否需要重新绘画,所以所有的绘制都需要在矩形框中进行。如果绘制超过了这个矩形框,Graphice View框架就不会将超出的那部分擦除。
通过下图就能看到上面几个数据是如何得到的,基本就是返回一个比item还要大一点的矩形。
Shape函数
Graphice View框架使用shape函数返回了形状来确定两个item是否相撞,所以shape函数返回的形状会更加精确点。代码所示,这个矩形相当于就是老鼠的椭圆大小,不考虑尾巴多出来那部分。
Main函数
首先创建了一个scene,之前讲到的scene坐标系就是这里创建的。scene左上角在(-300,-300),长和宽都是600像素。
QGraphicsScene scene;
scene.setSceneRect(-300, -300, 600, 600);
QGraphicsScene类是作为QGraphicsItems类的容器,它可以高效地决定每个item的位置和哪个item是可见的。
如下所示,Scene默认情况是使用索引算法,这种算法可以加速搜索item,比较适用于静态场景。但是如果场景有很多动画,我们可以设置为NoIndex,这样会比较快点。
scene.setItemIndexMethod(QGraphicsScene::NoIndex);
通过addItem给scene添加老鼠。
for (int i = 0; i < MouseCount; ++i) {
Mouse *mouse = new Mouse;
mouse->setPos(::sin((i * 6.28) / MouseCount) * 200,
::cos((i * 6.28) / MouseCount) * 200);
scene.addItem(mouse);
}
scene是为了管理item,但是不能让item可视化。可视化需要新建QGraphicsView类。
setRenderHin函数:设置了反走样\反锯齿(Antialiasing),主要是让线条更柔顺,不会边缘出现锯齿。
setBackgroundBrush函数:设置了view的背景图----奶酪图。
setCacheMode函数:设置缓存模式,加速渲染背景图。
setViewportUpdateMode函数:ViewportUpdateMode属性描述了View类在场景发生变化时是如何更新它的视图的,存在五种模式。
QGraphicsView::FullViewportUpdate:更新整个视图;
QGraphicsView::MinimalViewportUpdate:这是默认模式,寻求最小化区域进行更新;
QGraphicsView::SmartViewportUpdate:分析重绘区域,然后试图寻找最佳的模式;
QGraphicsView::BoundingRectViewportUpdate:(该例程选择模式),只更改界限以内的内容。这个模式缺点是就算其他item未发生改变,它的界限内也会发生重绘。
QGraphicsView::NoViewportUpdate:不更新试图,这种用户想要自己控制更新时选择的模式;
setDragMode函数:这个DragMode属性定义了当用户点击scene背景并且拖拽鼠标时会发生什么,存在三种模式。
QGraphicsView::NoDrag :忽略鼠标事件,不可以拖动。
QGraphicsView::ScrollHandDrag :光标变为手型,可以拖动场景进行移动。(本例程采用)
QGraphicsView::RubberBandDrag :进行区域选择,可以选中一个区域内的所有图形项。
QGraphicsView view(&scene);
view.setRenderHint(QPainter::Antialiasing);
view.setBackgroundBrush(QPixmap(":/images/cheese.jpg"));
view.setCacheMode(QGraphicsView::CacheBackground);
view.setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate);
view.setDragMode(QGraphicsView::ScrollHandDrag);
**setWindowTitle函数:设置窗口标题;
view.setWindowTitle(QT_TRANSLATE_NOOP(QGraphicsView, "Colliding Mice"));
view.resize(400, 300);
view.show();
创建一个定时器,连接定时器的timeout信号,到scene的advance槽函数。每一次定时器到了时间,就激活scene刷新场景。定时器的实际是1000/33毫秒。所以相当于动画一秒钟会刷新30帧。对于大多数动画来说,这个帧速率足够了。
QTimer timer;
QObject::connect(&timer, &QTimer::timeout, &scene, &QGraphicsScene::advance);
timer.start(1000 / 33);
本文转自:Qt Creator Colliding Mice碰撞老鼠例程解析【1.5W字数长文!详细!】_研究僧-彬彬的博客-CSDN博客