ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Qt坦克大战源码拆解:QGraphicsView图元、碰撞检测与主循环实战

Qt坦克大战源码拆解:QGraphicsView图元、碰撞检测与主循环实战

简介:这是一套面向具备C++基础、希望以实战方式理解面向对象与Qt游戏开发的坦克大战项目源码。项目围绕经典坦克大战玩法,将玩家坦克、敌方单位、子弹、地图格子、爆炸效果、状态管理与主窗口交互拆分为相对独立的模块,便于读者顺着工程结构梳理类设计、继承多态、信号与槽、场景渲染、定时器动画及碰撞检测等关键实现。压缩包共28个文件,约819KB,其中10个cpp与10个头文件构成核心逻辑和类声明,另含png、bmp图像素材、qrc资源文件、pro工程文件及LICENSE,可直接作为Qt工程学习与二次开发的基础。目前已有5656人学习。对于想从语法练习过渡到完整小游戏项目的开发者,这份源码能提供可运行的模块划分、事件处理与游戏循环思路,也便于在此基础上尝试地图编辑、道具扩展或升级系统等改造。

1. 从一份能编译通过的 Qt 坦克大战源码说起

前阵子有人甩给我一份 C++ 课程设计的压缩包,Qt 写的坦克大战,问能不能跑起来。我解压、用 qmake 生成工程、编译、点运行,窗口弹出来的那一刻还真有点意外:砖墙、钢墙、草丛、基地都在,坦克能开,子弹能打,砖墙被打碎还有爆炸特效。很多 C++ 学习者卡在 Qt 控件这一层——信号槽会连、按钮会摆,可一碰 QGraphicsView 就不知道从哪儿下手。这份 Qt 版坦克大战刚好卡在这个位置:规模可控,一个下午能通读完;闭环完整,地图加载、坦克移动、子弹飞行、碰撞销毁全都走了一遍;面向对象的痕迹也清晰,Tank、Bullet、Brick、Base各自成类,继承、虚函数、多态都用在了刀刃上。适合三类人:刚学完 C++ 语法想找个完整项目练手的,做课程设计需要一个能交差骨架的,以及想看看图元数量上百时 QGraphicsView 到底该怎么组织代码的。

2. 工程结构与技术选型:一个 QGraphicsScene 怎么装下整张战场

2.1 为什么不上 QWidget 手绘,而是走 QGraphicsView 框架

刚接触 2D 游戏的人第一反应通常是继承 QWidget、重写paintEvent、开个 QTimer 定时update()。这条路能走通,但走不远。原因很直接:一旦屏幕上同时存在几十个对象,你就得自己维护一个对象列表,自己算每个对象的包围盒,自己在paintEvent里遍历绘制,自己写「谁和谁撞了」的判断。渲染和逻辑搅在一个函数里,改一次玩法要动三处代码,血泪经验。

Graphics View 框架把这堆脏活接了过去。它把世界拆成三层:QGraphicsScene 是逻辑世界,管所有图元;QGraphicsItem 是世界里的一件东西,自带坐标、包围盒、Z 值和父子关系;QGraphicsView 是观察窗口,负责把场景映射到屏幕上,还顺手给了缩放、平移、视口裁剪。对坦克大战这种「一堆实体在一个平面里互相碰撞」的场景,最值钱的是两件事:scene->items(rect)能直接拿到某个矩形范围内的所有图元,scene->collidingItems(item)能直接告诉你这个子弹碰到了谁。自己实现这两句,保守估计得写两百行,还得处理四叉树或者网格分区,不划算。

这里有个容易混淆的点:QGraphicsScene 的坐标系和你屏幕像素不是一回事。场景坐标是逻辑坐标,View 才负责缩放。所以坦克的m_speed = 2.0说的是每帧移动 2 个场景单位,不是 2 个像素;窗口拉大时画面跟着拉伸,但坦克速度在逻辑世界里的绝对值不变。理解这一层,后面调参数才不会翻车。

2.2 四层拆分:视图、场景、实体、规则各管一摊

拿到源码先别急着一个文件一个文件读,先看它怎么分层。这份工程的划分是比较典型的四层,虽然原作者没写成文档,但目录结构把意图暴露得很清楚。

层次代表类职责不该做的事
视图层MainWindow、QGraphicsView建窗口、装 View、接键盘事件、暂停/重开按钮不写任何游戏规则
场景层GameScene持有所有图元、驱动定时器、加载关卡、判断胜负不写具体某个坦克怎么开火
实体层Tank、Bullet、Brick、Base自己的绘制、自己的移动、自己的碰撞反应不直接操作别的实体内部状态
规则层GameController、关卡数据刷怪节奏、计分、关卡通关门不碰 QPainter

这套划分的好处是改动收敛。想加一种新敌人,只在实体层加一个Tank的子类,场景层刷怪时换类型就行;想改刷新节奏,动规则层一个常量,别的文件一行不改。我见过不少人写小游戏喜欢把所有逻辑塞进 MainWindow,两千行之后想加个暂停功能都要改五个地方,那就是没分层的代价。

实体层和场景层之间的通信,常见做法是信号槽而不是直接函数调用。比如Tank开火时emit fired(bullet),GameScene收到后把子弹挂进场景并登记到自己的m_bullets列表;子弹命中后emit expired(this),场景收到后统一removeItem加deleteLater。这么绕一圈看起来啰嗦,但换来的是「谁创建谁销毁」的责任清晰——子弹由场景创建、由场景销毁,实体自己只管「我该没了」这个事实。

2.3 目录结构与 .pro 文件逐项说明

源码树大致长这样,不同人拿到的包可能名字有出入,但层级基本一致:

TankBattle/ ├── TankBattle.pro # qmake 工程文件 ├── main.cpp ├── mainwindow.h/.cpp/.ui # 视图层 ├── gamescene.h/.cpp # 场景层 + 主循环 ├── controller.h/.cpp # 刷怪、计分、关卡切换 ├── entities/ │ ├── tank.h/.cpp # 坦克基类 │ ├── player.h/.cpp # 玩家坦克(继承 Tank) │ ├── enemy.h/.cpp # 敌方坦克(继承 Tank) │ ├── bullet.h/.cpp │ ├── brick.h/.cpp # 可破坏砖墙 │ ├── steel.h/.cpp # 不可破坏钢墙 │ └── base.h/.cpp # 基地 ├── res/ │ ├── res.qrc # 资源清单 │ ├── images/ # 坦克、子弹、墙体贴图 │ ├── levels/ # 关卡文本 │ └── sounds/ └── README.md

.pro文件是整个工程能不能编译起来的关键,很多人下载完直接报「找不到 QGraphicsScene」,问题几乎都出在这:

QT += core gui widgets TARGET = TankBattle TEMPLATE = app CONFIG += c++11 CONFIG -= console # 不想运行时弹黑框就加上这句 INCLUDEPATH += $$PWD/entities SOURCES += \ main.cpp \ mainwindow.cpp \ gamescene.cpp \ controller.cpp \ entities/tank.cpp \ entities/player.cpp \ entities/enemy.cpp \ entities/bullet.cpp \ entities/brick.cpp \ entities/base.cpp HEADERS += \ mainwindow.h \ gamescene.h \ controller.h \ entities/tank.h \ entities/player.h \ entities/enemy.h \ entities/bullet.h \ entities/brick.h \ entities/base.h RESOURCES += res/res.qrc # 若用到音效模块,取消下面一行注释 # QT += multimedia

逐项说清楚:QT += widgets是必须的,QGraphicsView 系列类全在 widgets 模块里,只写core gui会链接失败;INCLUDEPATH += $$PWD/entities让#include "tank.h"这种扁平写法成立,不加就得写#include "entities/tank.h";CONFIG -= console在 Windows 上决定运行时是否附带一个命令行窗口,调试期可以留着看qDebug输出,发布前再去掉;RESOURCES += res/res.qrc决定图片能不能被编译进可执行文件,漏了这一行程序不会崩,但满屏都是白方块——这个坑后面还会细讲。

2.4 三条命令把工程跑起来

qmake 和 CMake 这两条路,源码包给的是哪条就走哪条。给 qmake 的工程,命令行流程是这样的:

# 1. 进入工程目录,生成 Makefile qmake TankBattle.pro # 2. 编译,-j4 表示 4 线程并行,数字按自己 CPU 核数改 make -j4 # 3. 运行(Linux/macOS);Windows + MinGW 下产物是 release/TankBattle.exe ./TankBattle

Windows 上一般直接用 Qt Creator 打开.pro更省事:选择套件(Kit)→ 构建 → 运行,构建目录建议自定义到源码目录之外的build/文件夹,避免中间产物和源文件混在一起导致二次编译时找不到文件。如果手上这份是 CMake 工程,把第一条换成cmake -B build -DCMAKE_PREFIX_PATH=/path/to/Qt然后cmake --build build即可,后面的逻辑完全一样。

3. 面向对象核心拆解:Tank、Bullet、Wall 三类图元怎么写

3.1 图元基类:选 QGraphicsObject 还是 QGraphicsItem

Qt 提供两个基类可选:QGraphicsItem 轻,但没有信号槽;QGraphicsObject 继承自 QGraphicsItem 和 QObject,多了信号槽和属性系统,代价是每个实例多一点点开销。游戏实体需要向外广播事件(开火、被击毁、命中),所以这里选 QGraphicsObject 是合理的,代价可以忽略。

派生类里必须实现两个纯虚函数:boundingRect()告诉场景这个图元占多大地方,paint()决定怎么画。boundingRect()写错的后果很严重——写小了,碰撞检测会漏判,子弹从坦克边缘擦过去打不中;写大了,两个并排的坦克会互相判定为碰撞,谁也别想动。所以这个矩形要和贴图实际尺寸严格对齐,一般做法是构造时读一次pixmap.size()存成成员,之后boundingRect()直接返回它,避免每帧重新查。

还有一个容易被忘的:想让qgraphicsitem_cast<Tank *>(item)正常工作,派生类必须重写type()并返回一个大于QGraphicsItem::UserType的固定值。不重写的话qgraphicsitem_cast永远返回空指针,碰撞处理里的类型分发会静默失效——不报错、不崩溃,就是打不死人,这种 bug 排查起来最费时间。

// entities/tank.h —— 坦克基类骨架 #pragma once #include <QGraphicsObject> #include <QPointF> class Bullet; class Tank : public QGraphicsObject { Q_OBJECT public: enum class Camp { Player, Enemy }; enum class Dir { Up, Down, Left, Right }; explicit Tank(Camp camp, QGraphicsItem *parent = nullptr); // QGraphicsItem 必须实现的两个接口 QRectF boundingRect() const override; void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override; // 自定义图元类型 ID,供 qgraphicsitem_cast 使用 enum { Type = QGraphicsItem::UserType + 1 }; int type() const override { return Type; } Camp camp() const { return m_camp; } Dir dir() const { return m_dir; } int hp() const { return m_hp; } void setDir(Dir d); // 转向并刷新贴图 void setSpeed(qreal s) { m_speed = s; } void takeDamage(int dmg); // 扣血,血量归零发信号 virtual void advanceStep() = 0; // 每帧由场景调用,子类各自实现 virtual void tryFire() = 0; signals: void fired(Bullet *bullet); void destroyed(Tank *self); protected: Camp m_camp = Camp::Enemy; Dir m_dir = Dir::Up; qreal m_speed = 2.0; // 单位:场景坐标 / 帧 int m_hp = 1; int m_cool = 0; // 开火冷却剩余帧数 QSizeF m_size = QSizeF(32, 32); QPixmap m_pix; }; // 统一的方向到单位向量映射,移动和子弹共用 inline QPointF dirToVector(Tank::Dir d) { switch (d) { case Tank::Dir::Up: return { 0, -1}; case Tank::Dir::Down: return { 0, 1}; case Tank::Dir::Left: return {-1, 0}; case Tank::Dir::Right: return { 1, 0}; } return {0, 0}; }

这段头文件把「共性」和「差异」分开了。共性放基类:阵营、朝向、血量、速度、冷却,以及那套必须实现的虚函数声明;差异留给子类,advanceStep()和tryFire()声明成纯虚,玩家坦克实现成「按方向键状态移动」,敌人坦克实现成「AI 决策移动」,两者互不干扰。dirToVector写成内联自由函数而不是成员,是因为子弹也需要它——子弹的飞行方向和坦克朝向用的是同一套映射,重复实现两份,迟早有一天改了一处忘了另一处。

3.2 坦克移动与碰撞:每帧走一步,先问能不能走

移动逻辑看起来简单,实际最容易出事。最朴素的写法是setPos(pos() + delta),跑起来立刻翻车:坦克会直接穿墙,穿过别的坦克,甚至开出场景边界。

正确的做法是「先算目标位置,再问能不能去」。思路是三步:根据当前朝向算出一帧的位移;用目标位置构造一个矩形,用scene()->items(rect)把这个矩形里的图元全捞出来;只要捞到任何一个不该穿过的实体,这一帧就不动。

// entities/tank.cpp void Tank::setDir(Dir d) { if (m_dir == d) return; m_dir = d; update(); // 只重绘自己,别调 scene()->update() } void Tank::takeDamage(int dmg) { m_hp -= dmg; if (m_hp <= 0) emit destroyed(this); // 由场景负责从 m_tanks 里摘掉并销毁 } void Tank::advanceStep() { if (m_cool > 0) --m_cool; // 冷却按帧递减,不是按毫秒 // 具体移动由子类调用 tryMove 完成,这里只提供一个受保护的公共实现 } // 放在 protected 里,玩家和敌人都复用 bool tryMove(QGraphicsObject *self, const QSizeF &size, const QPointF &delta, const QRectF &sceneRect) { const QPointF target = self->pos() + delta; // 1) 场景边界判定:用图元的中心点矩形,别用左上角 QRectF next(target.x() - size.width() / 2, target.y() - size.height() / 2, size.width(), size.height()); if (!sceneRect.contains(next)) return false; // 2) 实体阻挡判定 const auto blockers = self->scene()->items(next, Qt::IntersectsItemShape); for (QGraphicsItem *it : blockers) { if (it == self) continue; // 子弹不挡路,草丛不挡路,其余一律视为障碍 const int t = it->type(); if (t == Bullet::Type) continue; if (t == Grass::Type) continue; return false; } self->setPos(target); return true; }

几个参数值得展开。Qt::IntersectsItemShape是查图元时的匹配模式,另外还有Qt::ContainsItemShape(完全包含才算)和Qt::IntersectsItemBoundingRect(按包围盒算,更快但更粗糙)。坦克这种方块贴图,用IntersectsItemShape精度够用、性能也还能接受;如果后期实体数量涨到几百个,可以退一步改成按包围盒匹配,肉眼几乎看不出差别。

m_cool用帧数而不是 QElapsedTimer 计毫秒,是这类小项目的常规取舍。帧率由定时器固定,一帧冷却换算出来就是固定射速,代码简单,不用处理「这台机器帧率不稳」的问题。代价是以后真要接入变速或者暂停,冷却逻辑得跟着改——所以我把冷却递减放在advanceStep()里,跟主循环同频,改的时候只动一处。

3.3 子弹与命中判定:谁负责扣血,谁负责销毁

子弹是整份代码里生命周期最短、最容易出内存问题的一类图元。它的逻辑只有三件事:往前走一步、看看撞到谁了、撞到就通知对方然后自己申请销毁。

// entities/bullet.cpp enum { Type = QGraphicsItem::UserType + 5 }; int type() const override { return Type; } void Bullet::advanceStep() { const QPointF next = pos() + m_vel; // m_vel 由发射时根据朝向乘速度得到 // 飞出场景直接判死,别让它无限往外跑 if (!scene()->sceneRect().contains(next)) { emit expired(this); return; } setPos(next); const auto hits = scene()->collidingItems(this, Qt::IntersectsItemShape); for (QGraphicsItem *it : hits) { if (it == m_owner) continue; // 不打自己 if (it->type() == Bullet::Type) { // 子弹对撞,两发一起消失 emit expired(this); return; } if (auto *b = qgraphicsitem_cast<Brick *>(it)) { b->hit(); // 砖墙扣耐久,耐久归零自己发销毁信号 emit expired(this); return; } if (auto *s = qgraphicsitem_cast<Steel *>(it)) { Q_UNUSED(s) emit expired(this); // 钢墙只弹开,不损伤 return; } if (auto *t = qgraphicsitem_cast<Tank *>(it)) { if (t->camp() == m_owner->camp()) continue; // 不打友军 t->takeDamage(1); emit expired(this); return; } if (auto *base = qgraphicsitem_cast<Base *>(it)) { base->hit(); emit expired(this); return; } } }

这段代码的排序有讲究:先判子弹对撞,再判墙体,最后判坦克。要是把坦克判断提到墙体前面,子弹贴着墙飞过来时会先判定命中坦克,砖墙反而打不穿,手感会很怪。m_owner存的是发射它的坦克裸指针,不上锁也不加引用计数,因为坦克被销毁时GameScene会先把场上所有子弹清掉,生命周期上是安全的——但这条约定必须在代码注释里写死,否则新人接手后随手改一处销毁顺序,就是野指针崩溃。

collidingItems每帧对每颗子弹调一次,子弹几十发时开销可以接受。真想优化,思路是别每帧查,而是只在子弹位置变化跨越了瓦片边界时才查一次,或者干脆放弃 QGraphicsScene 的碰撞,改用自己维护的二维网格做空间分区。不过这种校级项目规模下,过早优化反而增加阅读成本。

4. 主循环与地图:QTimer 驱动的帧、瓦片地图和关卡数据

4.1 用 QTimer 当心跳,一帧做一次全量推进

游戏没有真正的 while 循环,靠的是 Qt 事件循环里插进来的定时器。每触发一次 timeout,就相当于游戏世界往前推进一帧。帧率定多少?16 毫秒约等于 60 FPS,是这类 2D 游戏最常见的取值;33 毫秒约 30 FPS,动起来会有点顿但省电。注意事项是定时器类型要显式设成Qt::PreciseTimer,默认的Qt::CoarseTimer在系统繁忙时可能偏差十几毫秒,坦克走起来一卡一卡的,很容易被误判成「代码性能有问题」,实际是定时器精度在捣鬼。

// gamescene.cpp GameScene::GameScene(QObject *parent) : QGraphicsScene(parent) { setSceneRect(0, 0, 26 * 32, 26 * 32); // 26x26 个 32px 瓦片 setItemIndexMethod(QGraphicsScene::NoIndex); // 图元频繁增删时关掉 BSP 索引 m_tick.setInterval(16); m_tick.setTimerType(Qt::PreciseTimer); connect(&m_tick, &QTimer::timeout, this, &GameScene::onTick); } void GameScene::onTick() { ++m_frame; // 1) 所有实体推进一帧,用副本遍历防止中途增删导致迭代器失效 const auto tanks = m_tanks; for (Tank *t : tanks) if (t->scene()) t->advanceStep(); const auto bullets = m_bullets; for (Bullet *b : bullets) if (b->scene()) b->advanceStep(); // 2) 每 90 帧尝试补一个敌人,场上上限 4 个 if (m_frame % 90 == 0 && m_aliveEnemy < 4) spawnEnemy(); // 3) 胜负判定 if (m_baseDestroyed) { m_tick.stop(); emit gameOver(false); } else if (m_aliveEnemy == 0 && m_remainEnemy == 0) { m_tick.stop(); emit gameOver(true); } }

两个细节值得说。第一,遍历时用const auto tanks = m_tanks;拿一份副本。这是踩过坑之后养成的习惯:advanceStep()内部可能触发expired信号,槽函数里会从m_tanks里removeAll,直接对原容器做范围 for 循环等于边迭代边删除,程序会以各种玄学方式崩在别处。第二,setItemIndexMethod(NoIndex)关掉场景的 BSP 索引树。默认情况下场景会为所有图元建一棵空间索引来加速items()查询,子弹和爆炸特效每秒增删几十次,索引树反复重建的开销反而超过查询收益。实体多且相对静止的场景才该保留索引。

4.2 瓦片地图:用二维数组描述关卡,用 QGraphicsPixmapItem 渲染

整张地图没有做成一张大图,而是拆成 32×32 的瓦片。好处是关卡可以用文本描述,改地图不用开绘图软件——打开levels/level1.txt改字符,重新编译就换了一张图。

# levels/level1.txt —— 26 列 x 26 行,字符含义见下 .......................... ..BBBB....SSSS....BBBB.... ..B..B....S..S....B..B.... ..B..B....S..S....B..B.... ..BBBB....SSSS....BBBB.... .......................... .....GGGG......GGGG....... .....G..G......G..G....... .......................... ..........BBBB............ ..........B..B............ ..........BBBB............ .......................... ....SSSSSS....SSSSSS...... .......................... .............PP........... ..........BBBBBB.......... ..........B....B.......... ..........B.BB.B.......... ..........B.BB.B.......... <- B 中间那两个格子是基地

字符到图元类型的映射写在加载函数里,B是砖墙、S是钢墙、G是草丛、P是玩家出生点、E是敌人出生点、W是水、.是空地。加载时逐字符扫描,遇到非空字符就 new 一个对应图元、setPos到瓦片中心、addItem挂进场景。

void GameScene::loadLevel(int index) { m_tiles.clear(); m_bullets.clear(); m_tanks.clear(); QFile f(QString(":/levels/level%1.txt").arg(index + 1)); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() << "关卡文件打开失败:" << f.fileName(); return; } QTextStream in(&f); int row = 0; while (!in.atEnd() && row < kRows) { const QString line = in.readLine().trimmed(); if (line.isEmpty()) continue; QVector<TileType> rowData; for (int col = 0; col < line.size() && col < kCols; ++col) { const QChar ch = line.at(col); rowData.append(charToTile(ch)); createItemAt(ch, col, row); // 生成图元,内部按 32x32 换算坐标 } m_tiles.append(rowData); ++row; } updateEnemyCounter(); // 统计本关敌人数量,供胜负判定用 }

createItemAt里做坐标换算的口诀是「列号乘 32 加 16」。因为QGraphicsPixmapItem的默认锚点在左上角,而瓦片中心对齐看上去更自然,所以setPos(col * 32 + 16, row * 32 + 16),同时setOffset(-16, -16)把绘制原点挪回中心。这一步做错的话,整张地图会整体偏移半个瓦片,肉眼看得出来但很难想到是哪里的问题。

m_tiles这份二维数组不只是给渲染用的,它同时是逻辑地图:敌人 AI 判断前方能不能走、寻路要不要绕,都是查这个数组而不是查场景里的图元。两套数据要保持一致,所以砖墙被打碎时必须同步把m_tiles[row][col]改成Empty,否则会出现「画面里墙没了但敌人还是不敢过」的奇怪现象。

4.3 敌人 AI 与刷怪节奏:状态机比随机数靠谱

最省事的敌人 AI 是每帧生成一个随机方向让坦克走,撞墙了就换一个。跑起来能看,但玩两分钟就腻:敌人会在墙角反复横跳,也不往基地方向靠。

稍微像样一点的做法是极简状态机,三个状态来回切:随机游走、追击玩家、攻击基地。每个状态有个最小持续时间(比如 60 帧),时间到期后按概率选择下一个状态。追击状态下的方向选择用「横向差值大就先走横向」这种朴素规则,不需要 A*。真要加寻路,把m_tiles当网格做 BFS 就行,26×26 的规模,BFS 一次不到一毫秒,完全够用。

void Enemy::advanceStep() { if (m_cool > 0) --m_cool; if (--m_stateTtl <= 0) { m_stateTtl = 60 + QRandomGenerator::global()->bounded(60); m_state = pickNextState(); // 按当前血量、玩家距离加权随机 } // 到达路口才决策方向,避免每帧抖动 if (isAtTileCenter()) { const Dir want = decideDir(); // 依状态算出期望方向 setDir(want); tryMove(this, m_size, dirToVector(want) * m_speed, scene()->sceneRect()); } else { // 不在格子中心就沿着当前方向补完这一步 tryMove(this, m_size, dirToVector(m_dir) * m_speed, scene()->sceneRect()); } if (m_cool == 0 && QRandomGenerator::global()->bounded(100) < 3) tryFire(); }

isAtTileCenter()这个判断是让 AI 看起来「有纪律」的关键。如果每帧都重新决策方向,敌人会在两个格子之间来回抽搐;只在格子中心做决策,它就走得方方正正,像个正经游戏。判断方式很简单,坐标对 32 取模,余数为 16(也就是格子中心)时返回 true,为了容错一般允许 ±2 的误差。

5. 避坑与排查:从编译报错到画面空白的五条血泪记录

5.1 编译期:报「未定义 QGraphicsScene」或链接错误

现象:error: 'QGraphicsScene' was not declared in this scope,或者编译过了但链接阶段一堆undefined reference to vtable for Tank。

原因有两个方向。一是.pro里只写了QT += core gui漏了widgets,Graphics View 系列全在 widgets 模块。二是新增了带Q_OBJECT宏的类,但没把.h加进HEADERS,moc 不会处理它,信号槽和 vtable 都没有,链接必然失败。

解决:检查.pro的QT +=行补上widgets;新增任何带Q_OBJECT的头文件,同步往HEADERS +=里加一行,然后执行「构建 → 执行 qmake」再重新编译。只点编译不重新 qmake,新加的 moc 文件不会生效,这个坑几乎每个 Qt 新手都要踩一次。

5.2 运行期:窗口一闪而过或者一片漆黑

现象:程序启动后窗口闪一下没了,或者窗口在但里面全空。

原因大概率是对象在栈上。MainWindow w; w.show();之后w离开作用域被析构,窗口自然消失。另一个常见写法错误是把QGraphicsScene建成局部变量传进setScene(),函数一返回,场景被销毁,View 里什么都不剩。setScene不会接管所有权,只存指针。

解决:窗口和场景都用new,窗口交给 Qt 管理,场景挂到 View 的父对象上,或者干脆用QGraphicsScene的构造函数传 parent。判断是否踩了这个坑,看程序是否「没崩溃但没画面」——如果是段错误,方向就换成查指针了。

5.3 内存:玩三分钟越来越卡,最后崩溃

现象:刚开局很流畅,敌人和子弹越积越多之后帧率掉到个位数,再玩一会儿直接闪退。

原因通常是把图元从场景移除时只调了removeItem(),没delete。removeItem只是把图元从场景的图元列表里摘出来,所有权还给你,不释放就永久泄漏。更隐蔽的一种是既delete了图元,又在别处持有它的裸指针继续用,那就是 use-after-free,崩溃点会出现在完全无关的位置,调试起来像黑匣子。

解决:统一销毁路径。场景维护m_bullets、m_tanks等列表,收到实体的expired/destroyed信号后,把它从列表removeAll,再removeItem+deleteLater()。用deleteLater而不是delete,因为信号可能在实体自己的成员函数里发出,此时对象还在栈上执行,立刻delete等于自己把自己埋了。另外销毁时顺手清空所有指向它的弱引用(m_owner之类),别留悬空指针。

5.4 逻辑:子弹打不穿墙,或者明明打中了却没反应

现象:子弹速度一快就穿过砖墙不爆炸;或者明明贴着坦克开火,对方掉血却没规律。

原因是每帧位移大于障碍物厚度时的「隧穿」。子弹速度 8、帧间隔 16 毫秒,一帧走 8 个场景单位,而砖墙的boundingRect()只有 32 宽,理论上不该穿;但如果子弹速度调到 20 以上且贴图偏小,一帧直接跨过整块墙,collidingItems查的时候子弹已经在墙那边了,自然判不中。

解决:分步推进而不是一次性位移。把每一帧的位移按不超过障碍物最小厚度的一半切分,循环调用检测;或者用QGraphicsScene::items()沿着子弹轨迹取一条线段的包围矩形查一次,再逐个判断。最省事的是把子弹速度压在 12 以内,32 格瓦片下基本不会隧穿——小游戏没必要为了几帧的视觉速度牺牲判定准确度。

5.5 资源:图片全变白方块,或者提示QPixmap: Cannot open file

现象:程序正常跑,坦克能操作,但所有贴图都是白色方块,控制台偶尔刷一行打不开文件的警告。

原因基本都在 qrc。要么.pro漏了RESOURCES += res/res.qrc,要么 qrc 里的路径写错,要么代码里用了系统路径images/tank.png而不是资源路径:/images/tank.png。注意那个冒号,它是「从编译进二进制的资源里取」的意思,漏掉就会去当前工作目录下找文件,程序从别的目录启动时必然找不到。

解决:路径统一写成:/前缀;确认 qrc 文件里的<file>节点是相对 qrc 所在目录的真实相对路径(注意 Linux 下大小写敏感,Tank.png和tank.png是两个文件);改完 qrc 必须重新执行一次 qmake 让 rcc 重新生成,光重新编译不够。想快速定位问题,在QPixmap加载后加一句qDebug() << pix.isNull();,true 就说明加载失败,不用满屏幕猜。

6. 二次开发:从换皮到加 AI,哪些地方能改、怎么验证

改这种项目,我的习惯是从最外层往里切,先做零风险的,再动核心逻辑。

第一层:换皮换资源。把res/images/下的贴图换成自己画的,尺寸不变就不用改一行代码;尺寸变了要同步改实体的m_size和生图元时的偏移量。关卡直接改levels/*.txt,26×26 的字符网格,新增一行E就多一个敌人出生点,改完不用动 C++。这一步零风险,适合先跑通改-编译-验证的循环。

第二层:加新型坦克。敌人坦克目前是单一类,想加「快速型」和「重甲型」,不要复制粘贴新类,用数据驱动:

属性普通敌人快速型重甲型玩家
速度1.52.51.02.0
血量1133
开火概率3%5%2%手动
得分100150300-

把这组值写成一个静态表,spawnEnemy()按权重随机取一个 type,构造时把参数注进Tank的成员。这样加新敌种只改表格,不改类。

第三层:加寻路 AI。前面提过,把m_tiles当网格跑 BFS。要点是把「基地」当成唯一目标点,每次决策前跑一次多源 BFS,从基地倒推出整张图的距离场,所有敌人共享同一份距离场,每帧只查表不重算。26×26 网格,BFS 一次不到 1000 次操作,即使每 30 帧重算一次也完全无感。验证方法很直接:把敌人数量调到 8,观察它们是否还会在墙角打转,还会就说明决策点不在格子中心。

第四层:加存档。用QJsonDocument把当前关卡号、剩余敌人数量、玩家血量和分数写成一个 json,存到QStandardPaths::AppDataLocation下,别硬编码到程序目录——那个目录多数情况下没有写权限。胜负判定处调一次保存,主窗口构造时读一次,几行代码的事。

验证这块需要单独说一句。游戏项目不好写单元测试,但可以埋两个钩子:一是暴露GameScene::stepForTest(int frames),让测试代码能手动推帧而不用等真实定时器;二是在onTick里按帧计数打日志,记录每帧实体数量。我见过一个「跑十分钟后越来越卡」的问题,靠这两行日志定位到是某个特效图元没有走销毁路径——没有帧计数,光看画面根本看不出来。

从那以后我每次接手这类 Qt 小游戏源码,第一件事都是先把帧计数和实体数量打到日志里,再动游戏逻辑。这比事后 debug 省太多事了。希望这份拆解能帮你把工程跑起来,也跑出自己的东西。

本文还有配套的精品资源,点击获取

返回列表