Qt坦克大战源码解析:C++面向对象与QTimer碰撞检测实战

发布时间:2026/10/29 14:14:16
Qt坦克大战源码解析:C++面向对象与QTimer碰撞检测实战 简介面向具备 C 基础、希望借 Qt 掌握游戏开发流程的学习者这份坦克大战源代码以完整可运行的实战项目切入帮助理解面向对象设计在游戏实体建模、事件处理与画面渲染中的落地方式。压缩包共 28 个文件约 819KB包含 10 个 cpp 与 10 个头文件承载坦克、主窗口、子弹、地图格子、状态及爆炸等模块另有 png/bmp 图像素材、qrc 资源清单与 pro 工程配置便于直接导入 Qt 调试。已有 5654 人学习说明其作为入门练手项目的参考价值较受认可。读者可从中梳理类职责划分、继承与多态的组织方式观察信号槽如何衔接键盘输入和游戏事件并借 QGraphicsView/QGraphicsScene、定时器与帧动画理解移动、射击、碰撞检测和爆炸反馈的实现地图编辑、分数生命值管理与升级、魔法攻击等扩展方向也留有继续改造的空间适合用来拆解结构并二次开发。1. 一份能编译的 Qt 坦克大战源码值不值得打开看第二遍判断标准其实很朴素一个 C Qt 坦克大战的实战项目源码只要它能在干净的 Qt 环境里编译通过、能跑起来、类之间有清晰的继承关系它就比十篇“面向对象三大特性”的教程有用。这份坦克大战源码走的是典型的 Widget 自绘路线——游戏画布是一个 QWidgetQTimer 驱动主循环坦克、子弹、墙体、爆炸各自成类玩家坦克和敌方坦克从同一个基类派生。适合三类人刚学完 C 语法、想找一个能从头读到尾的完整工程的学过 Qt 但只会拖控件的以及要交课程设计、需要一块能改得动的地基的。不适合找商业级引擎的人——它就是单机小游戏两三千行的量级价值在结构清楚每一处都能顺着读下去。2. 先把工程跑起来qmake 的 .pro 文件里三处必须改的地方很多人拿到源码第一件事是双击 .pro 让 Qt Creator 打开然后直接点运行接着收获一屏红色报错。这不能怪源码十有八九是这份代码写在 Qt4 时代而本机装的是 Qt5 甚至 Qt6模块名、C 标准、资源路径全变了。正确顺序是先读目录、再改工程文件、最后编译别跳步。2.1 目录结构决定了你该从哪个文件读起一个结构没写崩的坦克大战工程文件职责基本是固定的。打开之前先扫一眼能省掉后面一半的迷路时间。文件/目录职责二次开发时改动频率main.cppQApplication 入口创建并 show 主窗口低几乎不动mainwindow.h/.cpp主窗口、菜单栏、开始/暂停/重开中加 UI 入口时动gamewidget.h/.cpp游戏画布paintEvent 绘制 QTimer 驱动高核心战场tank.h/.cpp坦克基类与玩家/敌方派生类高手感和 AI 都在这bullet.h/.cpp子弹的生成、飞行、命中判定中map.h/.cpp地图格子、墙体类型、可通行判定高做关卡必改res.qrc images/图片、音效等资源低*.proqmake 工程描述一次性改对就不动读代码的顺序建议反过来先看 main.cpp 知道谁创建了谁再看 gamewidget.cpp 的构造函数找到定时器和 connect然后顺着 onTick 一路往下最后才进 tank.cpp 看具体逻辑。逆着调用链读比从基类头文件开始啃要快得多。2.2 .pro 里三处必须动的地方工程文件是整份源码最容易被忽略、又最容易卡死人的地方。下面这份是我一般会改成的最小可用版本改动点都写了注释。QT core gui widgets # Qt5 必须显式加 widgets只写 gui 会找不到 QApplication CONFIG c11 # 源码里用了 nullptr / override不抬标准会直接编译失败 TARGET TankGame TEMPLATE app DESTDIR $$PWD/bin # 产物统一丢 bin别和源码混在一个目录 SOURCES main.cpp \ mainwindow.cpp \ gamewidget.cpp \ tank.cpp \ bullet.cpp \ map.cpp HEADERS mainwindow.h \ gamewidget.h \ tank.h \ bullet.h \ map.h RESOURCES res.qrc # 图片一律走 qrc不要用磁盘相对路径第一处是QT widgets。Qt4 里 QWidget、QPainter 这些都挂在 gui 模块下Qt5 拆出了独立的 widgets 模块只写QT core gui会在链接期报一堆undefined reference to QApplication::QApplication新手看到这种报错往往以为是源码缺文件。第二处是CONFIG c11。老代码里的nullptr、override、for (Tank *t : m_tanks)这类写法在默认的 C98 标准下全部不合法。如果本机 Qt 版本较新直接写CONFIG c17也行但别低于 c11。第三处是RESOURCES res.qrc。这一行决定了运行时能不能加载到坦克贴图。漏了它编译照过跑起来满屏空白方块很多人会误判成绘制逻辑写错了实际上资源压根没编进去。2.3 第一次编译四条命令和三类高频报错习惯用 Qt Creator 的可以直接跳过命令行但纯命令行编译更容易看清错误出在哪一层。我一般这么走qmake -v # 先确认 qmake 指向哪个 Qt 版本多版本共存时这步很关键 mkdir -p build cd build # 影子构建别在源码目录里生成一堆 .o qmake ../TankGame.pro # 生成 Makefile make -j4 # Windows MinGW 用 mingw32-make -j4 ./bin/TankGame # 或者 ./TankGame取决于 DESTDIR 有没有配三类报错值得单独记一下。cannot find -lGL是缺 OpenGL 开发库Linux 下装libgl1-mesa-dev即可Project ERROR: Unknown module(s) in QT: widgets说明 qmake 指向的是 Qt4需要切到 Qt5 的 qmake 路径undefined reference to vtable for XXX几乎都是头文件里声明了虚函数却没在 .cpp 里实现或者 .pro 的 SOURCES 漏了对应的 cpp。提示第一次跑起来之前先把窗口尺寸固定住setFixedSize(...)比resize(...)更省事能避免调试期因为拉伸导致的坐标错乱干扰判断。3. 面向对象怎么落到坦克上四个类的继承与职责边界“面向对象”这四个字在教材里是概念在这种小游戏里是硬约束如果坦克、子弹、墙三者没有公共基类主循环里就得写三套遍历、三套碰撞、三套回收逻辑代码量翻倍还容易漏。这份源码的骨架之所以值得看就在于它把“每帧要更新、每帧要绘制、随时可能消失”这三件事抽成了一个基类。3.1 从 Unit 派生把 update 和 draw 提上去基类只放三样东西位置尺寸、存活标记、两个纯虚函数。够用而且不会过度设计。// unit.h —— 所有可见物体的公共基类 class Unit { public: Unit(int x, int y, int w, int h) : m_rect(x, y, w, h), m_alive(true) {} virtual ~Unit() {} virtual void update() 0; // 每帧逻辑纯虚函数逼派生类实现 virtual void draw(QPainter p) 0; // 每帧绘制具体画什么由子类决定 QRect rect() const { return m_rect; } bool isAlive() const { return m_alive; } void kill() { m_alive false; } // 只打标记不在这里 delete protected: QRect m_rect; // 碰撞盒同时也是绘制锚点 bool m_alive; };坦克类在其上补齐方向、速度、开火冷却和贴图数组// tank.h class Tank : public Unit { public: enum Dir { Up, Down, Left, Right }; Tank(int x, int y, Dir d, int speed) : Unit(x, y, 32, 32), m_dir(d), m_speed(speed), m_coolDown(0) {} void update() override; // 处理冷却递减 void draw(QPainter p) override; // 按 m_dir 选对应方向的贴图 bool move(Dir d, const Map map); // 先判可通行再改坐标返回是否真的动了 Bullet* fire(); // 造一颗子弹交出去自己不持有 protected: Dir m_dir; int m_speed; // 单位是像素/帧不是像素/秒换算时容易栽跟头 int m_coolDown; // 剩余冷却帧数归零才能开火 QPixmap m_pix[4]; // 四个朝向用数组比 switch 干净 };这里有个设计取舍值得说清fire()返回裸指针而不是让坦克自己保存子弹列表。子弹的生命周期归游戏画布管坦克只负责“造”不负责“收尸”。否则坦克和子弹互相持有回收时极容易出现悬空指针这类 bug 在单机小游戏里往往表现为“玩两三分钟后随机闪退”极难定位。3.2 QWidget 自绘和 QGraphicsView 两条路线怎么选市面上的坦克大战源码基本分两派选错了后面每一步都别扭。维度QWidget 自绘paintEventQGraphicsView 场景上手成本低一个 paintEvent 画全部中要理解 item / scene / view 三层碰撞判定自己算 QRect 相交可用 collidingItems省事对象管理手动容器遍历回收要自己写场景树托管removeItem 即可大量对象性能几十到几百个对象没问题上千对象才有明显优势资源占用轻相对重适合场景课程设计、单机小游戏复杂特效、需要缩放旋转这份源码走的是左边这条。理由很实际坦克大战同屏对象撑死几十个用场景图属于杀鸡用牛刀而且自绘路线能把“坐标系怎么换算”“碰撞盒怎么算”这些底层细节暴露出来对学习反而更有价值。判断标准也简单——如果你的同屏对象不超过两百个自绘就够了超过这个量级再考虑迁移到场景图。3.3 信号槽把输入、计时、碰撞串成一条线Qt 的事件驱动和游戏主循环之间需要一个转换层这个转换层就是 QTimer。整个游戏的驱动链其实只有三行 connect// gamewidget.cpp 构造函数里 m_timer new QTimer(this); m_timer-setInterval(16); // 约 60 帧实际受系统调度影响 connect(m_timer, QTimer::timeout, this, GameWidget::onTick); // 主循环脉冲 connect(m_restartBtn, QPushButton::clicked, this, GameWidget::resetGame); setFocusPolicy(Qt::StrongFocus); // 不设这行键盘事件永远收不到 m_timer-start();setFocusPolicy是最容易漏的一行。QWidget 默认不接受焦点keyPressEvent写了也白发表现为“程序能跑、坦克不动”然后开始怀疑人生。信号槽本身不需要多讲但要注意连接类型默认的 AutoConnection 在同一个线程里走的是直连等价于普通函数调用不存在“事件排队导致输入延迟”的问题这一点和某些跨线程场景完全不同别被网上的说法带偏。4. QTimer 主循环与碰撞检测帧率、坐标系和手感参数游戏能不能玩八成取决于这一章的三件事每帧干了什么、碰撞盒怎么算、参数取多少。前两件是正确性问题第三件是手感问题而手感问题往往比正确性问题更难调。4.1 onTick 里到底该放什么主循环函数的顺序是有讲究的先处理输入与状态更新再算碰撞最后统一重绘。顺序错了会出现“子弹先画出来再判定命中”的一帧延迟肉眼可见。void GameWidget::onTick() { if (m_paused) return; // 1) 逻辑更新输入已经在 keyPressEvent 里记进 m_keys这里只消费 for (Tank *t : m_tanks) t-update(); // 2) 子弹飞行与命中判定倒序遍历方便安全删除 for (int i m_bullets.size() - 1; i 0; --i) { Bullet *b m_bullets.at(i); b-update(); if (m_map.hitWall(b-rect()) || b-rect().intersects(m_player-rect())) { b-kill(); } if (!b-isAlive()) { m_bullets.removeAt(i); delete b; // 移除和 delete 必须成对顺序不能反 } } // 3) 统一重绘别在每个 setter 里各自 update() update(); }三个细节值得强调。倒序遍历不是为了性能是为了在删除当前元素后索引不失效update()只调一次Qt 会合并重绘请求散落在各处的update()会让帧率忽高忽低m_paused的提前 return 比暂停定时器更稳timer-stop()之后再start()容易把间隔重置掉。4.2 碰撞检测矩形相交和格子换算的两个边界坦克大战的碰撞本质上是“轴对齐矩形相交”不需要四叉树。真正容易出错的是移动前的可通行判定——必须把目标矩形换算成地图格子范围再逐格检查。// map.cpp —— 判断一个矩形区域是否全部可通行 bool Map::canPass(const QRect r) const { const int TILE 32; int c0 r.left() / TILE; int c1 (r.right() - 1) / TILE; // 减 1 是关键QRect 的 right 是包含端点的 int r0 r.top() / TILE; int r1 (r.bottom() - 1) / TILE; for (int y r0; y r1; y) for (int x c0; x c1; x) if (tileAt(x, y) Brick || tileAt(x, y) Steel) return false; return true; }-1这两处是经典的翻车点。QRect 的语义是“左上角坐标 宽高”right()返回的是x w - 1如果物体右边恰好压在格子边界上不做减一就会多算一格表现为坦克贴着墙走不动或者更诡异的“隔着一格撞墙”。另外要顺手做一次边界裁剪x、y超出地图尺寸时直接返回 false否则数组越界在某些编译器下不报错只是读到垃圾值排查起来非常费劲。4.3 手感参数表源码默认值和实际该取多少同一份逻辑参数差一点玩起来就是两个游戏。下面这组是我调完之后比较顺手的范围可以照着先跑一遍再微调。参数源码常见默认建议区间调大之后的影响定时器间隔16 ms16 ~ 33 ms数值越大越卡但 CPU 占用下降坦克速度2 px/帧1 ~ 4 px/帧太快会穿模因为碰撞只在帧边界判定子弹速度6 px/帧4 ~ 10 px/帧太慢会被自己追上同归于尽开火冷却20 帧10 ~ 30 帧太短可无限连发难度归零敌方转向概率3%1% ~ 5%太高原地打转太低直冲撞墙同屏敌人上限43 ~ 6影响的是遍历和判定的总开销速度参数和定时器间隔是联动的速度 × (1000 / 间隔)才是每秒像素数。把速度设成 4、间隔设成 33实际速度反而比速度 2、间隔 16 还慢。调手感时先固定间隔只动速度变量一次只改一个。注意碰撞只在帧边界发生所以单帧位移一旦超过最小障碍物宽度一个格子 32 像素就会出现穿墙。想提高速度又不穿模正确的做法是把一帧的位移拆成多个子步长来判定而不是直接调小碰撞盒。5. 避坑与排查编译通过不代表能玩下面这五条是我在这种 Qt 小游戏源码里反复遇到的现象都很像“玄学”成因却都很具体。5.1 窗口一闪就退或者干脆不出现现象命令行里能看到进程启动但窗口闪一下就没了或者根本没画出来。原因通常有两个main.cpp 里 GameWidget 建在栈上、出了作用域就被析构或者漏了show()另一种是 qrc 资源路径写错构造期加载贴图时返回空 QPixmap某些写法里会触发断言直接退出。解决方式是统一改成GameWidget *w new GameWidget; w-show();并在加载图片后加一句判空日志把资源问题从逻辑问题里摘出来。5.2 画面闪烁、拖影坦克走过留一串残影现象移动时后面拖着半透明的旧帧。原因是 paintEvent 里没有先清背景只是把物体往上叠。Qt5 的 QWidget 默认有双缓冲但前提是你自己填底。解决办法是在paintEvent开头先p.fillRect(rect(), QColor(20, 20, 20));同时确认整个场景只在 onTick 末尾调一次update()——每个对象各自调update()会导致局部重绘区域互相覆盖闪烁会更明显。5.3 按住方向键不动松开了反而一直走现象按键响应像是慢半拍或者松开后坦克还在滑行。原因是没处理keyReleaseEvent同时又没屏蔽键盘自动重复。正确的写法是用一个QSetint m_keys记录当前按下的键keyPressEvent里插入、keyReleaseEvent里移除onTick 里再根据集合内容决定移动方向。自动重复事件要显式忽略if (e-isAutoRepeat()) return;否则按住不放会触发大量重复事件。5.4 玩一会儿就崩崩溃位置每次都不一样现象跑几十秒到几分钟后随机闪退调试器指向的位置飘忽。九成是对象回收没做干净遍历容器的过程中delete了元素或者对象已经delete但指针还留在 QList 里下一帧继续访问就是野指针。规矩定死两条——第一不在遍历过程中删除先kill()打标记遍历结束统一清理第二删除必须和removeAt/removeAll成对出现顺序是先移除再 delete。另外警惕在成员函数里写delete this那是在给自己埋雷。5.5 换一台机器坦克全变成白方块现象本机跑得好好的拷给别人或者打包出去所有贴图消失。原因是用的是QPixmap(./images/tank.png)这种相对路径工作目录一变就找不到。解法是把图片全部编进res.qrc代码里用:/images/tank.png访问确实需要读外部文件的比如关卡地图用QCoreApplication::applicationDirPath()拼绝对路径而不是靠当前目录蒙。6. 二次开发与验证把写死的地图搬到文本里源码里地图通常是硬编码的二维数组想加一关就得改代码重编译这在实际使用中很别扭。把地图外置成文本文件是这类项目性价比最高的一次改造改关卡不用动 C还能顺手验证解析和碰撞两条路径是否正确。6.1 关卡文件格式与解析约定一套最小格式每个字符一格0空地、1砖墙、2钢墙、3出生点#开头为注释行。解析函数只做一件事——把文件读成二维整型表。// map.cpp —— 从 map1.txt 读关卡 bool Map::loadFromFile(const QString path) { QFile f(path); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false; m_grid.clear(); QTextStream in(f); while (!in.atEnd()) { QString line in.readLine().trimmed(); if (line.isEmpty() || line.startsWith(#)) continue; // 跳过空行和注释 QVectorint row; for (QChar c : line) { int v c.digitValue(); // 非数字字符返回 -1正好用来报错 if (v 0) { m_grid.clear(); return false; } row.append(v); } if (!row.isEmpty()) m_grid.append(row); } return !m_grid.isEmpty(); }digitValue()是个省事的小工具非数字返回 -1省掉一堆if (c 0 || c 1 ...)。返回值一定要检查m_grid为空时后续所有格子查询都会越界而这种越界在 Release 下往往不崩只是表现成“坦克能穿墙”特别迷惑。6.2 用一个编译开关把碰撞盒画出来改完地图最怕的是“看起来能走实际卡住”。与其猜不如把所有碰撞盒直接画在屏幕上。用DEFINES控制只在调试构建里生效# .pro 里debug 构建才打开 debug: DEFINES GAME_DEBUGvoid GameWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), QColor(20, 20, 20)); // ... 正常绘制顺序地图 - 坦克 - 子弹 - 爆炸 #ifdef GAME_DEBUG p.setPen(QPen(Qt::red, 1)); for (Unit *u : allUnits()) p.drawRect(u-rect()); // 红色方框 真实碰撞盒 p.setPen(Qt::green); p.drawText(8, 16, QString(units%1 bullets%2).arg(m_tanks.size()).arg(m_bullets.size())); #endif }跑起来之后红色方框和贴图对不齐就说明碰撞盒尺寸和贴图尺寸不一致这是“明明没碰到却被判死”的头号原因bullets数量只增不减说明回收分支压根没进回去查isAlive的判断条件。这三十行调试代码的价值比在qDebug()里堆一百行输出高得多。6.3 验证清单改完之后按这个顺序过一遍检查项怎么验不通过说明什么资源完整性断网、换目录运行qrc 没编进去或用了相对路径地图解析故意放一个非法字符返回值没检查缺容错碰撞盒对齐开 GAME_DEBUG 看红框尺寸和贴图不一致对象回收连续开火两分钟看内存曲线子弹容器泄漏或有野指针帧率稳定性同屏敌人拉到上限单帧逻辑太重或重复 update我吃过一次亏改完地图没开调试框结果新关卡的砖墙格子和绘制坐标差了半个格子玩家能大摇大摆从墙里穿过去测试时以为是“隐藏通道”上线前一晚才发现。从那以后我每次动完地图或碰撞相关的代码都强制先开GAME_DEBUG跑一遍红框再关掉做验收这个习惯救过我不止一次。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询