简介:面向计算机相关专业课程设计与毕业设计的Qt与C++简易植物大战僵尸游戏源码包,适合作为课程大作业、毕业设计或编程进阶的起步项目。资源共三十七个文件,以十七个C++源文件与十六个头文件为主,另含Qt资源文件、工程配置与说明文档,压缩包仅三十二KB,结构紧凑,便于导入Qt Creator直接编译运行。项目内的游戏地图、植物卡片、僵尸线程、阳光分数、音效控制等模块划分清晰,能够帮助学习者理解面向对象设计、事件驱动、多线程以及图形视图框架的综合运用;这样的模块化设计也提升了代码的可读性与可维护性。已有五百三十五人学习下载,源码已经过测试运行成功,可放心使用;若基础较好,也可在此基础上扩展新植物、关卡波次或动画效果,适合入门游戏开发并快速完成课程项目实践。
1. 课程设计里的 Qt 植物大战僵尸,难点从来不在绘图
拿到“基于 Qt 和 C++ 框架编写的简易植物大战僵尸游戏源码.zip”这类压缩包的人,大多不是没见过代码,而是想知道一个课设项目为什么非得这么分层、这么调度。这份源码的核心通常很直接:用 Qt 5 的 QGraphicsView 框架搭场景,把向日葵、豌豆射手、僵尸各自封装成继承 QGraphicsObject 的 C++ 类,再用 QTimer 驱动整个游戏逻辑。它不会复刻原版全部玩法,只要做到种植、出怪、发射豌豆、判定胜负这四条主线能闭环就算合格。比起用 QWidget 自绘,这类 Qt 游戏源码在对象管理上更接近真实项目,值得从业者看的其实是它的坐标换算、碰撞判定和帧循环设计,而不是界面本身。
2. 先定 C++ 对象模型:QGraphicsView 框架下的精灵继承结构
2.1 课程设计游戏为什么普遍选 QGraphicsView 而不是自绘
很多人在打开源码后第一眼看到QGraphicsScene、QGraphicsItem,误以为这是项目自己封装的一套框架,实际上是 Qt 官方提供的绘图框架。它和你在QWidget::paintEvent里拿QPainter把所有角色一张张画出来,最大的区别是省掉了三件重复劳动:场景坐标系、图元命中测试items(pos)、对大量可移动对象的批量管理。课程设计排期通常只有两到四周,你需要的是先让游戏跑起来,而不是先造一套移动、碰撞、选中的轮子。
如果在课设里直接用 QWidget 自绘,所有精灵的位置都要自己用 QRect 维护,鼠标点选要自己遍历,移除对象还要手动处理重绘区域,任何一个角落出问题,答辩演示都会卡在“为什么这个僵尸点不到”上。QGraphicsView 则把精灵当成独立QGraphicsItem,每个对象有独立的setPos、boundingRect,Qt 在内部做重绘裁剪。另一个容易被忽略的点是:项目里的 Zombie 继承的往往是QGraphicsObject,而不是QGraphicsItem,目的就是让每个游戏对象天然携带 QObject 信号槽能力,比如僵尸死亡时发射zombieDied信号,由场景统一结算分数。
提示:在 Qt 开发里,
QGraphicsItem只是一个纯绘图条目,不带事件循环和信号槽;QGraphicsObject才是带 QObject 能力的版本。课设代码里如果用 Item 反而要自己回传事件,通常会麻烦不少。
2.2 三类 C++ 对象的分工:基类只做矩形和绘制入口
常规的做法是把游戏对象分成三层:一个GameItem基类提供行列号、HP、绘制接口;Plant和Zombie继承它,各自实现自己的行为;LevelScene这个QGraphicsScene子类负责持有对象列表并做碰撞结算。基类代码大致是这样一个结构:
class GameItem : public QGraphicsObject { Q_OBJECT public: explicit GameItem(QGraphicsItem* parent = nullptr) : QGraphicsObject(parent) {} int row() const { return m_row; } int col() const { return m_col; } void setGridPos(int row, int col) { m_row = row; m_col = col; } protected: // 告诉场景这个精灵占用的矩形,碰撞和重绘都依赖它 QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget = nullptr) override; int m_row = 0; int m_col = 0; int m_hp = 100; };boundingRect()是这套框架里最关键的返回值,它决定了场景认为这个精灵“占了多大地方”。很多课设里的植物显示在草地上,但点击判定却偏了几个像素,问题基本都是boundingRect()返回的矩形和绘制内容不对齐。paint()只负责画,不要在里面去修改数据,否则当 Qt 因窗口遮挡触发重绘时,对象的血量可能被误改。
子类的差异集中在“行为和冷却”上。课程设计里最常见的需求是豌豆射手按间隔发子弹、向日葵产阳光、僵尸沿直线前进并啃植物,代码里可以用两个子类把这三种行为收敛起来:
class Plant : public GameItem { Q_OBJECT public: enum PlantType { Sunflower, Peashooter, CherryBomb }; PlantType type() const { return m_type; } bool canShoot(qint64 nowMs) const { return m_type == Peashooter && nowMs - m_lastShotMs >= m_fireIntervalMs; } void recordShot(qint64 nowMs) { m_lastShotMs = nowMs; } private: PlantType m_type = Peashooter; qint64 m_lastShotMs = 0; int m_fireIntervalMs = 1500; }; class Zombie : public GameItem { Q_OBJECT public: void setSpeed(qreal pxPerTick) { m_speed = pxPerTick; } qreal speed() const { return m_speed; } bool isEating() const { return m_eating; } void setEating(bool eating) { m_eating = eating; } int attackIntervalMs() const { return m_attackIntervalMs; } private: qreal m_speed = 0.6; // 每个逻辑帧移动的像素数 bool m_eating = false; int m_attackIntervalMs = 800; };这里有个容易踩的速度坑:m_speed以“每逻辑帧”为单位,而不是“每秒”。因为 QTimer 固定 50ms 触发一次逻辑帧,0.6 像素每帧相当于每秒 12 像素。如果把速度理解成 m/s,再把setInterval从 50ms 改成 20ms 后去重调速度,很容易出现僵尸突然变慢或快到贴脸的情况。调参时只改一个位置,这是对象模型的收益。下表是课设里类与职责的常用划分:
| 类 | 主要成员 | 容易用错的地方 |
|---|---|---|
| GameItem | row/col、hp、boundingRect | 把绘制内容画到 boundingRect 之外 |
| Plant | type、射击冷却 | 把冷却逻辑放在 paint 里 |
| Zombie | speed、isEating、攻击间隔 | 用像素坐标当作行号去对碰撞 |
| LevelScene | 植物网格、对象列表 | 在遍历列表时直接删除元素 |
2.3 谁负责“咬”和“被打”:把结算放进场景而不是对象自身
一个新手很容易写出的实现是:让 Zombie 在移动的advance()方法里直接去减Plant::hp。这个写法在“只有一个僵尸、也不移除对象”的演示里能跑,但只要出现两个僵尸同时咬一棵植物,就会出现双重扣血或遍历列表时对象已经失效的问题。常见做法是让僵尸只设置isEating(true),并记录当前啃咬目标;每个逻辑帧结束时由LevelScene统一调用resolveFights(),根据僵尸的目标做一次伤害结算。
这就是为什么源码里通常有一个独立的checkCollisionAndDamage()方法。它不负责绘制、不负责移动,只负责把“接触”和“伤害”变成两件互不干扰的事情。对象各自上报状态,场景做最终裁决,这个思路在整个 Qt 小游戏源码里都适用。
3. 种植模块的坑:把点击坐标换算成棋盘格坐标
3.1 坐标系的三种身份:视图坐标、场景坐标、格子坐标
在 Qt 的 QGraphicsView 框架里,鼠标点击至少经过两次坐标变换:QMouseEvent::pos()是视图坐标,需要先mapToScene得到场景坐标,再换算为行和列。课设源码里最常出现的问题是点击植物栏应该种在第五格,结果种到了第一格,就是因为没有区分“场景坐标”和“格子坐标”。
棋盘格的常量建议直接写成编译期常量,放在场景类的头文件里,后面所有换算共用一组数字:
constexpr int kGridRows = 5; constexpr int kGridCols = 9; constexpr int kGridCellWidth = 80; constexpr int kGridCellHeight = 96; constexpr QPointF kPlayFieldTopLeft(25, 140); QPointF gridOrigin(int row, int col) { return QPointF(kPlayFieldTopLeft.x() + col * kGridCellWidth, kPlayFieldTopLeft.y() + row * kGridCellHeight); } bool sceneToGrid(const QPointF& scenePos, int* rowOut, int* colOut) { QPointF local = scenePos - kPlayFieldTopLeft; if (local.x() < 0 || local.y() < 0) return false; int col = static_cast<int>(local.x()) / kGridCellWidth; int row = static_cast<int>(local.y()) / kGridCellHeight; if (row >= kGridRows || col >= kGridCols) return false; *rowOut = row; *colOut = col; return true; }kGridCellWidth不是背景图片的像素宽度除以列数得到的估算值,而应该是“可种植区域的实际宽度除以列数后向下取整”,否则点击第 9 格时会越界。kGridRows = 5、kGridCols = 9对应原版草坪的大致格数,换成 6 行 10 列也可以,只要保证gridOrigin和sceneToGrid使用的是同一套常量即可。kPlayFieldTopLeft一般取背景图左上角第一个格的左上角坐标,推荐把它定义成QPointF而不是两个独立 int,避免在传参时把 x、y 写反。
3.2 种植判定与 setPos 偏移:为什么植物总差半格
场景的鼠标事件里,完整流程是:点击时取scenePos,换算成格子,判断该格是否已经有植物,再生成新植物并加到场景。常见代码如下:
void LevelScene::mousePressEvent(QGraphicsSceneMouseEvent* event) { QPointF scenePos = event->scenePos(); int row = 0, col = 0; if (!sceneToGrid(scenePos, &row, &col)) return; // 点在草坪外,忽略 if (m_plantGrid[row][col] != nullptr) return; // 该格已有植物 Plant* plant = createPlant(m_pendingPlantType, row, col); QPointF center = gridOrigin(row, col) + QPointF(kGridCellWidth / 2, kGridCellHeight / 2); plant->setPos(center - plant->boundingRect().center()); addItem(plant); m_plantGrid[row][col] = plant; QGraphicsScene::mousePressEvent(event); }plant->setPos(center - plant->boundingRect().center())这行是整个种植逻辑里最容易抄错的地方。setPos设置的是图元左上角所在的场景位置,而 gridOrigin 给出的是一格的左上角。如果不减去boundingRect().center(),植物会整体向右下偏移,看起来就像被“平移”了半个格子。更麻烦的是,碰撞检测也拿boundingRect来做,所以尽管视觉上植物在格子里,实际判定矩形已经压到下一格去了。
createPlant是一个工厂方法,内部根据m_pendingPlantType生成不同的植物子类。课程设计如果做了选择栏,通常是在点击卡片时更改这个成员变量;没做选择栏就默认豌豆射手,也能跑通演示。
3.3 碰撞判定:按行进方向做轴向比较,不要迷信 intersects
植物和僵尸的碰撞,常见做法不是调用mapRectToScene(zombie->boundingRect()).intersects(...),而是只比较“僵尸的左边界”和“植物的右边界”。这样做的原因是:场景里还有豌豆子弹和其他装饰物,用矩形相交容易把“子弹打到僵尸”和“僵尸咬到植物”混在一起。
轴向判定的写法是:
bool isZombieTouchingPlant(const Zombie* zombie, const Plant* plant) { if (zombie->row() != plant->row()) return false; qreal zLeft = zombie->scenePos().x() - zombie->boundingRect().width() / 2; qreal pRight = plant->scenePos().x() + plant->boundingRect().width() / 2; return zLeft <= pRight; }这个函数只判断僵尸是否碰到了植物的右边界,逻辑上假设僵尸从右侧沿 X 轴负方向前进。scenePos().x()减去宽度的前半段,得到图元实际左边缘;如果这个左边缘已经越过植物的右边缘,说明两者在水平方向发生了重叠,再加上同行的前提,就是一次有效接触。相比矩形相交,它的好处在于不需要处理 Y 轴的冗余判断,也不会因为植物贴图里有一块透明区域而提前触发。
4. 用 QTimer 驱动主循环:豌豆冷却、逻辑帧与资源打包
4.1 固定步长逻辑:把渲染交给 Qt,把节奏留给自己
一个 Qt 植物大战僵尸课设的“主循环”通常不是 while 循环,而是QTimer。起点是启动游戏时创建一个 50ms 触发一次的定时器,每次触发调用场景类的onTick():
void LevelScene::startGame() { m_elapsedMs = 0; m_timer = new QTimer(this); m_timer->setInterval(50); m_timer->setTimerType(Qt::PreciseTimer); connect(m_timer, &QTimer::timeout, this, &LevelScene::onTick); m_timer->start(); setGameState(GameRunning); } void LevelScene::onTick() { m_elapsedMs += 50; trySpawnZombie(); for (Plant* plant : m_plants) plant->onTick(m_elapsedMs); for (Zombie* zombie : m_zombies) zombie->onTick(m_elapsedMs); for (Pea* pea : m_peas) pea->onTick(m_elapsedMs); resolveFights(); cleanDeadObjects(); }执行顺序是有讲究的:先让植物和僵尸更新状态,再让豌豆移动,最后统一结算伤害并清理死亡对象。如果把resolveFights()放在移动之前,会造成画面已经交错、但判定还在上一帧位置的错位感。m_elapsedMs是全局逻辑时钟,所有冷却和波次表都依赖它,而不是依赖QTime::currentTime(),这样可以保证暂停和继续时逻辑不会乱。
Qt::PreciseTimer这个参数容易被忽视。默认定时器在系统省电模式下可能被合并,导致游戏突然卡一下然后跳帧。对于 50ms 的逻辑帧,合并一次可能就是一次明显的僵尸瞬移。定时器间隔对课程设计手感影响很大,可以按下面的表选:
| interval | 表现 | 适用情况 |
|---|---|---|
| 20ms | 逻辑流畅,动画细腻,CPU 占用高 | 机器性能富余的演示机 |
| 50ms | 折中,适合绝大多数课设 | 最常用的默认值 |
| 100ms | 僵尸移动一顿一顿,豌豆偏慢 | 需要明显放慢节奏时临时调 |
如果是在自己电脑上复现源码,编译环境建议直接用 Qt 自带的 MinGW 套件,省去 vs 版本匹配的折腾。MSVC 版本不匹配时,Qt 经常报microsoft visual c++ 14.0 or greater is required之类的错误,这属于环境问题,和源码逻辑无关。
4.2 豌豆冷却不是“每秒减 1”,而是比较绝对时间戳
射击冷却最常见的错误实现是:每个 tick 里让一个计数器减 1,减到 0 就发射子弹。这个做法在窗口被拖动、定时器跳帧时会导致连续补发,看起来就像豌豆射手抽风。常见的落地做法是让Peashooter保存上次射击的绝对时间戳,每次逻辑帧比较差值:
void Peashooter::onTick(qint64 nowMs) { if (type() != Peashooter) return; if (m_lastShotMs == 0) m_lastShotMs = nowMs; if (nowMs - m_lastShotMs >= m_fireIntervalMs) { m_lastShotMs = nowMs; emit requestShoot(row(), scenePos().x() + boundingRect().width()); } }关键在于初始情况下m_lastShotMs为 0,第一次 tick 要把它设为当前时间,否则第一发子弹会在启动瞬间立刻发射。信号requestShoot把行号和作用点的 X 坐标传给场景,场景负责生成豌豆图元并加入m_peas列表。把子弹生成改成信号而不是在 Plant 内部直接创建 Pea,是为了让 Plant 不依赖具体场景对象,替换植物类型时不用改信号逻辑。
豌豆碰撞僵尸后要移除自身,这时的对象生命周期管理比较麻烦。不建议在collidingItems()的循环里直接deleteLater()再继续遍历,因为 Qt 的碰撞列表不会马上更新,会出现一次命中让豌豆穿过僵尸的视觉残留。更稳妥的顺序是:先标记死亡,加入m_peasToRemove列表,等resolveFights()结束后统一清理。
4.3 把图片和音效收进 qrc,让源码包直接可跑
课程设计源码的压缩包一旦换目录,最常见的崩溃就是图片加载不到。QImage("assets/sun.png")这种相对路径写法在 Windows 上依赖当前工作目录,双击运行和 Qt 调试器运行时的工作目录不一样,结果就会一个能跑一个黑屏。把资源收进 qrc 文件是最稳的做法,qt 绘图相关的代码统一用资源路径:
<RCC version="1.0"> <qresource prefix="/game"> <file alias="sunflower.png">assets/sunflower.png</file> <file alias="peashooter.png">assets/peashooter.png</file> <file alias="zombie.png">assets/zombie.png</file> <file alias="die.wav">assets/die.wav</file> <file alias="shot.wav">assets/shot.wav</file> </qresource> </RCC>在 .pro 文件里加上RESOURCES += res.qrc后,代码里用":/game/peashooter.png"就能加载图片。音效部分有一个高频坑:QSoundEffect只支持 WAV 格式的未压缩音频,把 MP3 塞进 qrc 后播放会静音。课程设计里如果需要“僵尸死亡”音效,先用格式工厂或 ffmpeg 转成 16bit 44.1kHz 的 WAV 再导入,比在代码里尝试各种播放器类都省事。另外,压缩包如果传给老师后解压路径里带中文,qrc 资源不受影响,但外部相对路径的音频文件会失效,这也是“把资源全部收进 qrc”作为通用建议的原因。
5. 答辩前把这三块调稳:帧率面板、出怪节奏和可回放日志
5.1 右上角显示真实帧率,用数字解释逻辑帧
答辩时最怕被问“你知道游戏跑多少帧吗”。与其现场猜,不如在界面上直接放一个QLabel,每 500ms 刷新一次。实现方式是在onTick()末尾调用一个统计函数:
void LevelScene::updateFpsLabel() { static int frames = 0; static qint64 lastMs = 0; frames++; qint64 nowMs = m_elapsedMs; if (nowMs - lastMs >= 500) { double fps = frames * 1000.0 / (nowMs - lastMs); m_fpsLabel->setText( QString("FPS: %1 / LogicTick: %2ms") .arg(fps, 0, 'f', 1) .arg(m_timer->interval())); frames = 0; lastMs = nowMs; } }这里显示的 FPS 是刷新次数,不是逻辑帧数。场景一有变化,QGraphicsView就会触发重绘,所以 FPS 通常会高于 20;而逻辑帧由QTimer决定,永远是 50ms 一条。答辩时强调“游戏手感由逻辑帧决定、画面由 Qt 自动重绘”这句话,比单纯展示运行效果更有说服力。
5.2 出怪节奏别用纯随机,用波次表控制
随机出怪在演示时很容易出现两分钟不出怪、观众干等的尴尬局面。常见做法是把出怪规则写成波次表,用m_elapsedMs做时间轴:
struct WaveRule { int startMs; int zombieCount; int countPerSpawn; }; std::vector<WaveRule> waves = { { 5000, 3, 1 }, { 20000, 6, 2 }, { 40000, 10, 3 }, };startMs是波次启动时间,zombieCount是这一波总僵尸数,countPerSpawn是每次刷出几只。波次表的好处是可以在演示前明确说出“20 秒后会有第一波密集僵尸”,也能在调试时把某波次的zombieCount调小来测试后期植物强度。
5.3 用 qDebug 打事件日志,回放最后几秒
当“僵尸走到某行却不咬植物”这种问题出现时,只靠看屏幕很难判断是碰撞没触发还是行号判断错了。在关键事件点打日志,是课设排错里成本最低的手段:
qDebug() << "tick" << m_elapsedMs << "spawn zombie row=" << row << "pos=" << zombie->scenePos();日志里带上m_elapsedMs和scenePos(),复盘时能直接从时间戳对出“僵尸已经到达第 3 行第 7 格,但 isEating 一直是 false”。把这三个验证点收进源码包后,课设演示基本不会在“为什么僵尸不动”上翻车。
本文还有配套的精品资源,点击获取