简介:面向高校C++课程设计场景的Qt坦克大战游戏项目,适合需要完成射击类大作业的学生,也适合希望借助完整实例入门Qt游戏开发的开发者。项目基于Qt 5.14.1与gcc 7.3.0构建,实现了35个关卡、每关20个敌方坦克、3条生命值以及大本营守护机制;玩家通过WASD控制移动、J键射击,敌方坦克具备自动移动与攻击逻辑,击败20个敌人自动进入下一关,生命耗尽或大本营被击中则游戏失败。资源以zip压缩包形式提供,大小约27.79MB,可直接下载用于学习或二次开发。目前已有1152人浏览学习。从中可以了解C++与Qt配合构建2D游戏的整体流程,包括窗口绘制、按键响应、碰撞检测、关卡切换与AI行为设计;同时也能作为课程设计交付模板,帮助梳理从环境配置到游戏循环、碰撞处理、对象管理的完整思路,并在此基础上扩展关卡、敌人类型或双人模式。
1. 用Qt写一个坦克大战,别让大作业停在“方块搬家”:它在考你这三件事
用Qt做坦克大战的C++大作业,很多人交上去以后心里是虚的:坦克能走、子弹能飞,但代码是几百个临时变量加超长if堆出来的,老师一问就露馅。一辆能跑完整个地图的坦克,真正考验的是类设计、事件驱动和对象生命周期这三件事——它们正好是一门C++课最想考你的地方。这篇笔记写给期末要交C++大作业、想用Qt把坦克大战做完做透的同学,也适合想把QGraphicsView框架用明白的从业者。我会从画布选型讲到碰撞检测、地图文件、AI与编译避坑,代码块可以直接抄,参数也按可运行的标准给。
2. 选对画布再写坦克:为什么我劝你用QGraphicsView而不是QPainter或QML
2.1 QPainter、QWidget自绘、Graphics View三种路线的取舍
初看坦克大战,很多人第一反应是“用QPainter在paintEvent里全画出来”。这个思路做静态界面没问题,做游戏就吃力——坦克、子弹、墙体要自己维护坐标集合,每一帧都要把整个场景重画一遍,命中检测还得自己遍历所有对象写矩形相交。代码写起来很快,但改需求时你会发现自己陷进一座屎山。
第二种是QWidget自绘,也就是把每个坦克做成一个QWidget。这更糟:widget数量一多,重绘开销直线上升,而且widget天生是矩形,想做旋转、层级、局部事件处理都别扭。QWidget适合做按钮、面板这样的控件,不适合做游戏里的动态对象。
真正的正解是QGraphicsView框架。它自带场景(QGraphicsScene)、视图(QGraphicsView)和图元(QGraphicsItem)三层结构。场景管理所有图元的坐标、层级和碰撞;视图负责把场景渲染到窗口;图元自己决定怎么画、怎么响应事件。官方封装好了拾取(itemAt)、碰撞(collidesWithItem)、坐标变换(mapFromScene)这些游戏里天天要用的功能,你不需要自己写,相当于白送。
有人问QML行不行。QML做动态界面确实漂亮,动画声明式写法很顺手,但这里有个现实的坑:如果你交的是C++课程的大作业,老师要看的是你对C++面向对象、STL、内存管理的掌握程度。QML把UI层和逻辑层拆得太开,还带一份脚本语言进来,作业味道反而偏了。Qt的MVVM框架更多是用在QML那套界面上,做坦克大战这种实时游戏属于杀鸡用牛刀。大作业要稳,就用QWidget + QGraphicsView,这也是我见过的大多数高分Qt小游戏选择的组合。
下面这张表方便你一眼看清三种路线的差异:
| 路线 | 图元管理 | 碰撞检测 | 重绘效率 | 大作业风险 |
|---|---|---|---|---|
| QPainter | 自己维护结构体 | 自己写矩形相交 | 整帧重绘,慢 | 代码失控 |
| QWidget自绘 | 每个对象一个widget | 自己处理事件 | widget过多会卡 | 对象生命周期混乱 |
| QGraphicsView | Item树自动管理 | 内置shape碰撞 | 局部更新,快 | 学习曲线稍陡 |
2.2 最小可运行骨架:场景、视图、一个占位图元
选型定了,先把骨架搭起来。我这里给出一份能在Qt Creator里直接跑的main.cpp,它把场景、视图、一个占位兵渲染出来,你先验证环境和工程配置没问题,再往里填坦克逻辑。
// main.cpp #include <QApplication> #include <QGraphicsScene> #include <QGraphicsView> #include <QGraphicsRectItem> #include <QColor> int main(int argc, char *argv[]) { QApplication app(argc, argv); // 场景相当于游戏世界的坐标系,所有图元都在这个坐标系里摆放 QGraphicsScene *scene = new QGraphicsScene; scene->setSceneRect(0, 0, 800, 600); // 约定世界边界 scene->setBackgroundBrush(QColor(40, 42, 48)); // 深色背景 // 视图是窗口层面的渲染器,负责把场景投影到屏幕 QGraphicsView *view = new QGraphicsView(scene); view->setRenderHint(QPainter::Antialiasing); // 抗锯齿,边缘更平滑 view->setFocusPolicy(Qt::StrongFocus); // 让视图能接收键盘焦点 view->resize(820, 640); // 临时放一个红色方块,验证图元能正常添加到场景 QGraphicsRectItem *block = scene->addRect(100, 100, 50, 50); block->setBrush(QColor(220, 50, 50)); view->show(); return app.exec(); }这里几个参数是后面所有游戏逻辑的地基。setSceneRect(0, 0, 800, 600)给场景定了边界,后面坦克移动、子弹射出都可以拿这个范围做越界判断;setRenderHint(QPainter::Antialiasing)会让矩形边缘平滑,但代价是重绘稍慢,对大作业来说利大于弊;setFocusPolicy(Qt::StrongFocus)是新手最容易漏掉的设置,没有它,视图拿不到键盘焦点,后面的keyPressEvent根本不会触发,坦克会变成一尊雕像。
跑通这份代码,你的Qt环境就确认没问题了。之后再往下走,遇到看不懂的报错时,也能大概率确定不是环境问题,而是代码问题。
2.3 图元坐标与层级:谁在谁上面,谁挡住谁
坦克大战里,墙体要挡住坦克,子弹要盖在坦克上面,计分文字要永远在视野最上层。这些关系在QGraphicsView里靠图元的层级和边界规则解决。
setZValue(double)控制绘制顺序:值越大,绘制时越靠上。我一般定这样一套约定:墙体z=0,坦克z=1,子弹z=2,HUD文字z=100。这样子弹从坦克炮管射出时不会穿到坦克后面,分数也不会被飞过的子弹挡住。层级值没有强制规定,只要保持相对关系清晰即可。
boundingRect()是图元自己报告给场景的“占地范围”,场景的碰撞检测和重绘判断都靠它。你画一个矩形坦克,boundingRect就返回左上角坐标加宽高。这个值必须画得比实际图形略大或者刚好贴合,否则会出现“明明没碰到墙,场景却说撞了”的鬼现象。新手常见错误是把boundingRect写死成QRectF(0, 0, 50, 50),但paint里实际画了个半径为30的圆,这时候碰撞区域和视觉区域对不上,调试难度翻倍。
itemAt(pos, transform)则用来回答“这个屏幕坐标点上有什么”,可以做鼠标点选坦克、点击地块等操作。注意它返回的是最上层的图元,如果你要判断点是否落在某个指定坦克上,得先拿到那个图元再对比。这一节没写代码,但它是后面做地图编辑器、点选调试的基础,建议先跑通2.2的骨架,再手写几个不同z值的方块叠在一起看看itemAt的返回结果。
3. 让坦克动起来:键盘事件、QTimer刷新与碰撞检测的配合与参数
3.1 按键输入:重写keyPressEvent,别用QShortcut做游戏操控
坦克大战的按键要持续监听WASD和方向键,最直接的做法是继承QGraphicsView,重写keyPressEvent和keyReleaseEvent。QShortcut适合做快捷键,比如打开菜单、保存地图,它不适合做游戏操控,因为你很难处理“按住不放”和“多键同时按”的细节。
// tankview.h #ifndef TANKVIEW_H #define TANKVIEW_H #include <QGraphicsView> class TankItem; class TankView : public QGraphicsView { Q_OBJECT public: explicit TankView(QGraphicsScene *scene, QWidget *parent = nullptr); protected: void keyPressEvent(QKeyEvent *event) override; void keyReleaseEvent(QKeyEvent *event) override; private: TankItem *tank_ = nullptr; }; #endif // TANKVIEW_H// tankview.cpp #include "tankview.h" #include "tankitem.h" #include <QKeyEvent> TankView::TankView(QGraphicsScene *scene, QWidget *parent) : QGraphicsView(scene, parent) { setFocusPolicy(Qt::StrongFocus); } void TankView::keyPressEvent(QKeyEvent *event) { if (!tank_) { QGraphicsView::keyPressEvent(event); return; } const qreal step = 4.0; // 每次按键的移动步长,单位是场景坐标下的像素 switch (event->key()) { case Qt::Key_W: tank_->setMoveDirection(QPointF(0, -1)); // 上:y 轴负方向 break; case Qt::Key_S: tank_->setMoveDirection(QPointF(0, 1)); // 下:y 轴正方向 break; case Qt::Key_A: tank_->setMoveDirection(QPointF(-1, 0)); // 左:x 轴负方向 break; case Qt::Key_D: tank_->setMoveDirection(QPointF(1, 0)); // 右:x 轴正方向 break; case Qt::Key_Space: tank_->tryFire(); // 空格开火 break; default: QGraphicsView::keyPressEvent(event); break; } } void TankView::keyReleaseEvent(QKeyEvent *event) { if (event->key() == Qt::Key_W || event->key() == Qt::Key_S || event->key() == Qt::Key_A || event->key() == Qt::Key_D) { tank_->setMoveDirection(QPointF(0, 0)); // 松开方向键就停下 } else { QGraphicsView::keyReleaseEvent(event); } }这一步只记录方向,不直接移动坦克。移动交给定时器每帧做,这样按一次键和按住键的结果是连续的,不会出现“按一下跳一截”的顿挫感。方向用(0,-1)这类归一化向量表示,好处是后续速度调整时,只要乘一个speed_就行,坦克的移动逻辑与按键逻辑彻底解耦。
注意Qt::Key_W这里用的是WASD布局,如果你要兼容方向键,在switch里把Qt::Key_Up这些分支也写上即可。还有一个小坑:Windows下有些输入法会拦截按键事件,导致按W弹出了中文输入框。大作业展示现场最好切到英文输入法,或者在看代码里调用setAttribute(Qt::WA_InputMethodEnabled, false)关掉输入法支持,不然演示时很容易翻车。
3.2 QTimer驱动游戏循环:为什么不用“按一下动一下”
“按一下动一下”的移动方式在坦克大战里表现为:按住W,坦克挪一步就停了。原因是keyPressEvent在普通模式下只触发一次,想要连续移动,要么自己开定时器,要么依赖系统的按键自动重复。
我一般用QTimer。它是一个事件驱动的定时器,到点后发timeout信号,触发onTick槽函数,每帧更新坦克位置、子弹位置并做碰撞检测。固定时间步长能让游戏逻辑不受屏幕刷新率影响,逻辑稳定,这是游戏循环里一个重要参数。
// gamescene.cpp 片段 #include <QTimer> void GameScene::startGameLoop() { QTimer *timer = new QTimer(this); timer->setTimerType(Qt::PreciseTimer); // 尽量按指定间隔触发 timer->setInterval(16); // 约 60 FPS,一帧 16 毫秒 connect(timer, &QTimer::timeout, this, &GameScene::onTick); timer->start(); } void GameScene::onTick() { // 1. 根据坦克当前方向移动坦克 tankItem_->moveByStep(); // 2. 移动所有子弹 for (BulletItem *bullet : bulletItems_) { bullet->advanceStep(); } // 3. 统一做碰撞检测,决定哪些子弹消失、哪辆坦克扣血 checkCollisions(); // 4. 检查胜利/失败条件 checkGameState(); }setInterval(16)对应60FPS,不是越高越好。帧率越高,每帧移动距离越小,碰撞检测越准,但CPU占用和绘制开销也上去了。坦克大战这种慢节奏游戏,16毫秒已经足够流畅。如果你的场景里图元很多,或者你画了复杂的paint路径,可以降到33毫秒(约30FPS),再用插值把移动做得平滑,大作业不需要纠结这个。
moveByStep()是坦克的方法,内部做的是setPos(pos() + direction_ * speed_),speed_通常取2到4像素每帧。速度参数需要和场景尺寸匹配:800x600的场景,速度4意味着每秒约250像素,横穿整张地图需要3秒出头,手感比较合适。太大容易穿模,太小会显得拖沓。我一般先用速度3跑一遍,卡顿就降到2,太慢再提到4,通常一次就能定下来。
3.3 碰撞检测:collidesWithItem、boundingRect和shape的区别
坦克撞墙是坦克大战核心交互。QGraphicsView自带collidesWithItem接口,默认按图元的shape()做相交判断。shape()默认由boundingRect()生成,所以如果你没有重写shape(),那碰撞检测就是矩形相交检测——对坦克和墙来说完全够用。
子弹打墙则容易出现视觉穿模,因为子弹画的是椭圆,但碰撞区域是矩形,弹尖还没碰到墙,碰撞判定就触发了。解决办法是在图元里重写shape(),返回精确的QPainterPath。下面用坦克的碰撞回退写法演示最经典的“撞墙就回退”逻辑:
// gamescene.cpp 碰撞检测片段 void GameScene::checkTankWallCollision() { const QPointF oldPos = tankItem_->pos(); // 先按当前速度向前试图移动一帧 tankItem_->moveByStep(); // 如果坦克和任何墙体图元相交,就退回上一帧的位置 const QList<QGraphicsItem *> walls = wallGroup_->childItems(); for (QGraphicsItem *wall : walls) { if (tankItem_->collidesWithItem(wall)) { tankItem_->setPos(oldPos); // 回退,相当于这帧白走 break; } } }回退是一种简单的碰撞响应方案,实现简单,适合坦克大战。坦克撞墙不是贴墙上,而是刚好卡在墙外一格,视觉上足够自然。更复杂的方案是计算推挤向量把坦克沿墙面滑出去,那个手感更顺滑,但代码量翻倍,大作业不划算。
说到碰撞检测的精确度,有个原则:墙体图元越多,遍历开销越大。把地图里所有墙体放到一个QGraphicsItemGroup里,然后只检测坦克和墙组是否碰撞,性能会好很多。另外,collidesWithItem的第二个参数是Qt::ItemSelectionMode,默认Qt::IntersectsItemShape是合理的,不需要改。如果遇到“明显没接触却被判定碰撞”,先检查图元的boundingRect是否画得比视觉图形大,这是最常见的原因,也是很多Qt崩溃和穿模的源头。
4. 关卡地图、敌军AI、计分面板:把大作业从“能跑”补到“能看”
4.1 用文本文件定义关卡:地图是数据,不是代码
很多人的地图是用几十行scene->addRect硬编码出来的,画面是能看,但老师问“能不能换一关”时,你就要改代码重编译。把地图抽到文本文件里,地图就变成了数据,改关卡不需要动一行代码。
// 地图格式约定: // # = 砖墙,T = 玩家出生点,E = 敌军出生点,空格 = 空白地 // map/level1.txt ################# #T # # ##### # # # # # # # E # # # E# #################// mapreader.cpp 加载地图的核心代码 #include <QFile> #include <QTextStream> #include <QGraphicsRectItem> void GameScene::loadMap(const QString &path) { QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return; // 打不开直接返回,后面再做错误提示 QTextStream in(&file); const int tile = 40; // 每格边长,场景 800x600 对应 20x15 格 int row = 0; while (!in.atEnd()) { const QString line = in.readLine(); for (int col = 0; col < line.size(); ++col) { const QChar ch = line.at(col); const qreal x = col * tile; const qreal y = row * tile; if (ch == u'#') { QGraphicsRectItem *wall = scene()->addRect(x, y, tile, tile); wall->setBrush(QColor(130, 130, 130)); wallGroup_->addToGroup(wall); // 所有墙放进一个组,方便碰撞检测 } else if (ch == u'T') { createPlayerTank(x + tile / 2, y + tile / 2); // 出生在格子中心 } else if (ch == u'E') { enemySpawnPoints_.append(QPointF(x + tile / 2, y + tile / 2)); } } ++row; } }这里几个点值得展开。第一,用QTextStream读文本而不是直接用QDataStream,因为地图文件是人类可读的文本,出错时你能自己打开看;第二,wallGroup_->addToGroup(wall)把墙图元塞进一个组,碰撞检测时遍历组内子项,大大减少循环次数;第三,出生点存进enemySpawnPoints_,敌军复活逻辑可以反复从这里取坐标,而不是每关手写坐标。
地图文件放在哪里也有讲究。路径写死绝对路径,比如C:/users/xxx/...,作业拷贝到另外一台电脑就崩。我习惯把地图文件放进Qt资源系统,用:/maps/level1.txt这样的路径访问,Qt Creator在保存工程时会自动把资源文件打包进二进制,交作业时只交源码和可执行文件就够了,不会出现缺文件的事故。
4.2 敌军坦克的简单AI:随机转向与间隔开火
敌军坦克不需要多聪明,随机转向加定时开火就能制造足够的压迫感。关键是控制几个定时参数,让AI行为看起来“像那么回事”。
// enemytank.cpp 片段 void EnemyTank::onTick() { // 每隔 90 帧(约 1.5 秒)随机换一个方向 if (frameCount_ % 90 == 0) { const QVector<QPointF> dirs = { QPointF(0, -1), QPointF(0, 1), QPointF(-1, 0), QPointF(1, 0) }; setMoveDirection(dirs[QRandomGenerator::global()->bounded(4)]); } // 每隔 120 帧(约 2 秒)尝试开火一次 if (frameCount_ % 120 == 0) { tryFire(); } moveByStep(); ++frameCount_; }这里的QRandomGenerator::global()->bounded(4)是Qt 5.10以后推荐的随机数写法,比老的qrand() % 4更安全,也避免了你手动管理种子。90帧和120帧这两个参数很有意思:90帧转向意味着平均1.5秒变一次方向,玩家有足够时间反应,又不会觉得敌军太呆;120帧开火则保持一定弹药密度。如果调太高,玩家还没靠近就被流弹打死,体验很差;调太低,敌军就是移动血包。
要让AI更有层次,可以再加一个“短距离直线追踪”:每30帧检测一次玩家坦克在自己前方多少像素内,如果距离小于300像素且中间无墙,就朝玩家方向开火。这个逻辑写起来很简单,但演示效果拔群,老师会觉得你考虑了游戏性。
4.3 分数、生命与过关判定:用QGraphicsTextItem做HUD
分数和生命不要画在窗口标题栏,要用场景内的QGraphicsTextItem作为HUD,跟随场景缩放,不会被视图遮挡。下面是最小实现:
// hud.cpp 片段 #include <QGraphicsTextItem> #include <QFont> class Hud : public QGraphicsTextItem { public: explicit Hud(QGraphicsItem *parent = nullptr) : QGraphicsTextItem(parent) { setPlainText(QString("Score: 0 Lives: %1").arg(3)); setDefaultTextColor(Qt::white); setFont(QFont("Microsoft YaHei", 14)); setZValue(100); // 永远显示在最上层 setPos(10, 10); } void updateScore(int score) { setPlainText(QString("Score: %1 Lives: %2").arg(score).arg(lives_)); } private: int lives_ = 3; };HUD的三个参数值得说明。setZValue(100)保证它盖过所有坦克和子弹;setPos(10, 10)固定在场景左上角,不受坦克移动影响;字体用“Microsoft YaHei”是为了Windows下中文不糊。这样一个简单的类,不需要重写paint,分数更新时调用updateScore,文字自动重绘。
过关判定则留意一个细节:别在for循环里删除图元。敌人坦克死亡时会从场景移除,如果边遍历敌人列表边删,迭代器会失效,导致崩溃。常见做法是把死亡敌人放到一个“待删除”列表,遍历结束后再统一清理。这也是Qt项目里高中低分作业的分水岭之一。
5. 编译与运行避坑:Qt版本路径、MSVC乱码、图元所有权与关闭崩溃
5.1 报错“:-1: error: dependent '........\qt\5.15.2\msvc2019_64\include\qtwidget...”直接卡死编译
这个报错样子的出现,通常是在Qt Creator里打开别人的工程,或者把工程目录换过位置之后。原因不是代码写错了,而是qmake缓存里还残留着旧环境的路径。
解决顺序是先删掉构建目录,再重新qmake。如果还报,就检查工程文件(.pro)里有没有声明Qt模块。用QGraphicsView的项目必须在.pro里写QT += core gui widgets,漏了widgets这个模块时,Qt 5就会出现这类“dependent include找不到”的问题。手动添加后保存,Qt Creator会提示重新构建,点一次qmake再编译就好了。这条是Qt C++工程的“玄学高发区”,但九成是路径和模块声明问题,不要一上来就重装Qt。
5.2 中文乱码:按钮和日志全是“锟斤拷”
现象是代码里写的QString("得分"),运行后变成乱码或问号。原因是Windows上MSVC编译器默认按本地代码页(GBK)解析源码,而你的文件用UTF-8保存,两者对不上。
解决方法是让MSVC按UTF-8读源码,在.pro里加一行:
msvc: QMAKE_CXXFLAGS += /utf-8注意只有MSVC工具链需要这个,MinGW一般不需要。加了这条之后,源码里的中文字符串、中文注释都能正常显示。另一个习惯是写Qt字符串时用QStringLiteral("得分"),它能让编译器在编译期就构造好字符串常量,减少运行时开销,也降低字符编码踩坑的概率。另外,地图文件里的中文标签(如果有)读取时,确保用QTextStream并设置setEncoding(QStringConverter::Utf8),Qt 6下尤其做一次,否则关卡名里的中文会是乱码。
5.3 手动delete图元后,程序退出一半就崩溃
现象是游戏运行正常,但关闭窗口或场景析构时程序直接崩。原因是QGraphicsScene对添加到场景里的图元拥有所有权,场景析构时会统一delete所有图元。如果你又在代码里手动delete item,同一个指针就会释放两次,这是C++内存管理里的经典翻车。
解决办法分两种场景。如果图元一直在场景里,把内存管理全交给场景,自己不要delete;如果你确实要提前移除一个敌人坦克,正确姿势是先scene->removeItem(item),让场景和它解除关系,然后再delete item。顺序不能反过来,反过来会触发“item还在场景中却被删除”,紧接着就是未定义行为。还有一个小细节:QGraphicsItem不是QObject,它没有deleteLater(),不能用信号槽的方式延迟释放。
5.4 关闭窗口后,定时器还在跑导致了奇怪的崩溃
现象是点窗口右上角关闭,程序不退出,或者退到一半卡住,控制台报一堆和QTimer相关的错误。原因是QTimer的父对象设成了MainWindow,但坦克、场景这些对象还在运行;窗口虽然关了,事件循环并没有完全终止,定时器依旧触发onTick(),此刻函数内部访问的图元已经被析构。
我的习惯是在重写MainWindow的closeEvent里显式停掉所有游戏定时器:
// mainwindow.cpp void MainWindow::closeEvent(QCloseEvent *event) { gameTimer_->stop(); // 先停定时器,阻止 onTick 继续跑 scene_->clear(); // 再清空场景,释放所有图元 QMainWindow::closeEvent(event); }scene_->clear()会删除场景里所有图元并重置选中状态,这一步可以把坦克、子弹、墙体一次性清干净。如果关窗口还是卡住,优先检查onTick里有没有访问已经被clear掉的指针,这几乎是我见过最多的小游戏关闭崩溃来源。
5.5 用VSCode+CMake跑Qt工程,编译通过运行却缺“Qt6Widgets.dll”
现象是项目能编译成功,但运行exe时系统弹窗提示找不到Qt6Widgets.dll或类似的Qt动态库。原因通常是CMake没有把Qt的bin目录加到PATH,或者CMAKE_PREFIX_PATH指向了错误的Qt安装位置。
如果你用CMake,先确认find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets)这一句找的是你实际用的Qt路径。可以在CMakeLists里加一行message(STATUS "Qt dir: ${Qt6_DIR}"),编译时看输出,路径不对就重新指定。运行exe时,把Qt的bin目录临时加到PATH再跑,能跑通说明库路径没问题,再考虑怎么打包。这条对不熟悉CMake的同学是血泪经验,与其在博客上求助,不如先检查这个变量值。
6. 还想拿高分:把坦克封装成交互类,用QTest给碰撞检测兜底
如果你的作业已经不满足于“能动就行”,可以再做两件事:第一是让坦克继承QGraphicsObject而不是裸的QGraphicsItem,第二是写一个最小的QTest测试来证明你的碰撞检测不是黑匣子。
继承QGraphicsObject的好处是图元本身具备信号槽、定时器和动态属性。坦克开火可以变成一个信号,敌人死亡可以广播给场景计分,逻辑会干净很多。下面是一个精简骨架:
// tankitem.h class TankItem : public QGraphicsObject { Q_OBJECT public: explicit TankItem(QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void setMoveDirection(const QPointF &dir); void moveByStep(); signals: void fire(); private: QPointF direction_{0, 0}; qreal speed_ = 3.0; };paint()负责绘制坦克外观:车体、履带、炮管。注意paint的参数option->state里有一堆状态位,比如被选中、悬停,你不需要全支持,但至少不要忽略它。绘制时用painter->save()和painter->restore()包住所有绘制操作,否则你设置的画刷会泄漏到图元外,别人的坦克或墙体被染上颜色,这种bug非常隐蔽。
QTest测试可以对碰撞判定做一次快速验证。写一个独立的小测试程序,把坦克和墙体放在重叠位置,断言碰撞结果是true:
// test_collision.cpp #include <QtTest> #include <QGraphicsScene> #include <QGraphicsRectItem> class TestCollision : public QObject { Q_OBJECT private slots: void overlapShouldReturnTrue() { QGraphicsScene scene; // addRect 返回的图元归 scene 所有,不需要手动 delete QGraphicsRectItem *wall = scene.addRect(0, 0, 40, 40); QGraphicsRectItem *tank = scene.addRect(38, 38, 40, 40); QVERIFY(tank->collidesWithItem(wall)); } }; QTEST_MAIN(TestCollision) #include "test_collision.moc"这个测试里,坦克左上角在(38,38),右边和下边都伸进了墙体2像素,按矩形相交必然返回true。你还可以顺手试一下坦克放在(40,40),贴边不重叠时返回值是什么,能加深你对boundingRect和shape边界的理解。测试通过后,以后改碰撞逻辑,只要跑一次这个文件就能确定没把原有功能搞坏,性价比极高。
最后一个建议来自我自己的Qt作业经验。交作业前我会做三件事:第一,删掉整个build目录重新编译一次,排除缓存问题;第二,把窗口缩到最小再拉大,确认视图能自适应;第三,把地图文件故意写错一两个字符,确认程序不会崩而是友好提示。这三件事帮我救回过不少次期末演示现场。坦克大战的Qt实现方案到这里已经覆盖了从选型到打包避坑的绝大部分场景,希望帮到你。
本文还有配套的精品资源,点击获取