简介基于QT框架与C实现的植物大战僵尸游戏源代码定位为C程序设计期末课程设计项目适合计算机专业学生、QT入门开发者及游戏编程爱好者参考学习。整套源码共31个文件包含13个cpp实现文件、12个h头文件以及qrc资源文件、ui界面文件、pro工程文件和ts翻译文件压缩包仅20KB结构非常精简。项目展示了QT在GUI开发中的优势涵盖植物与僵尸类设计、碰撞检测、游戏状态管理等核心逻辑并涉及QGraphicsView/QGraphicsScene绘图、事件处理、多线程更新、文件读写等知识点。已有3084人学习下载可作为课程设计选题、代码阅读训练或项目重构的参考资料。通过分析源码读者可以掌握C面向对象编程与QT图形界面开发的协同方法理解小型游戏项目的工程组织方式对提升综合编程能力具有直接帮助。1. 用QT复刻植物大战僵尸一套能应付期末课程设计的C方案期末课程设计年年都有人选植物大战僵尸但能真正把「植物」「僵尸」「子弹」这三样在窗口里跑起来的人不多。很多人卡在同一个地方用控制台写逻辑很顺一到窗口界面就不知道代码往哪放。基于QT的植物大战僵尸游戏源代码本质是一套用C和Qt图形框架把「游戏逻辑」「界面交互」「回合判定」串起来的工程方案。它能解决课程设计里最要命的三个问题代码量够不够、界面能不能看、答辩会不会被问倒。适合刚学完C面向对象、想拿课设练手Qt的读者照着一步步搭两三天能做出一个能现场演示的版本。2. 选QT而不是纯C图形视图框架与信号槽如何托住游戏逻辑2.1 课程设计为什么都拿QT做界面三件白给的能力Qt 是跨平台的 C 图形界面库做这类课设主要看中三样东西。第一是 QGraphicsView / QGraphicsScene 这套图形视图框架场景里可以直接放各种图元对象植物是 QGraphicsItem僵尸是 QGraphicsItem子弹也是 QGraphicsItem引擎每帧去操作这些对象比手动维护一个对象数组再自己调 paint 省力得多。第二是 QTimer 计时器游戏里的移动、产阳光、刷僵尸都靠它驱动不需要自己开线程。第三是信号槽点击卡片、植物发射、子弹命中这些事件天然适合用信号槽来解耦。对期末课程设计来说这三点能把工作量砍掉一半以上。版本选择上也有一点玄学很多同学直接装最新版结果到实验室电脑上编译不过或者老师演示用的老环境跑不起来。我一般会固定到一个长支持版本比如 Qt 5.15 或者兼容性较好的 Qt 6 分支并在项目说明里写明版本号。课程设计求稳不求新能编译、能演示、能截图比功能多一个版本特性重要得多。2.2 QGraphicsView还是QWidget先选框架再写代码很多教材讲窗口绘图只讲继承 QWidget 重写 paintEvent于是一上手就把所有绘制塞进 paintEvent 里。植物要自己 for 循环画僵尸要记录坐标自己擦除重画子弹位置要自己算窗口一刷新整块重绘。前期只有几个植物时很流畅一加入僵尸和子弹掉帧、闪烁、坐标错乱全来了。QGraphicsView 方案把这些问题交给了框架每个 QGraphicsItem 自己管理绘制、位置、碰撞范围场景负责调度视图只负责显示。对比如下方案对象管理碰撞检测鼠标拾取代码组织QWidget paintEvent手动维护对象数组手动矩形相交手动换算坐标绘制和逻辑混在一起QGraphicsView Scene场景统一管理 Itemshape 自动相交自带事件分发逻辑在 Scene显示在 Item代价是要补一点 Qt 的坐标系知识。Qt 里 view、scene、item 是三层坐标鼠标在 view 上的坐标要经过mapToScene转成 scene 坐标才能和植物的网格位置对齐。这个转换不复杂但第一次写课设的人很容易忽略具体坑在第 5 章会展开。2.3 信号槽与游戏事件流的对应点击、生产、死亡通知植物大战僵尸的事件流可以拆成四条链点击卡片 → 检查阳光数 → 创建植物图元向日葵定时器触发 → 产生阳光点豌豆射手定时器触发 → 生成子弹子弹撞到僵尸 → 僵尸掉血 → 死亡动画 → 移除图元。信号槽就是用来接这四件事的。植物本身不知道阳光数够不够也不知道子弹该往哪飞它只负责发信号由场景统一处理。这样写的好处是植物类不依赖场景类场景类只需要 connect 一下。// plant.h —— 植物基类中的自定义信号 #ifndef PLANT_H #define PLANT_H #include QObject #include QGraphicsItem class Plant : public QObject, public QGraphicsItem { Q_OBJECT public: enum Type { Sunflower, Peashooter }; explicit Plant(Type type, QGraphicsItem *parent nullptr); signals: void sunProduced(int row, int col); // 产生阳光时发出 void bulletFired(const QPointF start); // 发射子弹时发出 void plantDied(Plant *self); // 植物死亡时发出 }; #endif // PLANT_H这个类同时继承了 QObject 和 QGraphicsItem双继承必须让 Q_OBJECT 宏出现在类定义里否则自定义信号无法通过元对象系统注册。.pro文件里也要确认QT widgets已打开Qt 6 默认不会自动包含 widgets 模块。接线放在场景类里connect(peashooter, Plant::bulletFired, this, GameScene::spawnBullet); connect(sunflower, Plant::sunProduced, this, GameScene::gainSun); connect(plant, Plant::plantDied, this, [this](Plant *p) { removePlant(p); });信号参数的类型要和槽函数一致Qt 5 之后可以用指针成员函数lambda 表达式可以直接写在 connect 里像第三个例子一样但是 lambda 里如果用到了 this要注意 this 的生命周期场景析构前确保所有连接已经断开。习惯上我还会在场景析构时手动disconnect()一遍能少掉一批莫名其妙的崩溃。2.4 槽函数仍然建议写进public slots区Qt 5 之后新语法连接信号和普通成员函数也能工作很多教程就不提slots关键字了。但课程设计场景里老师可能就是在老的 Qt 4 或 Qt 5 早期环境下编译或者用老式SIGNAL/SLOT宏检查代码。我一般会把自定义槽写在public slots:区块里成本为零兼容性最好答辩时讲到信号槽机制也更有底气。3. 从空工程到能跑的最小游戏类划分、场景与信号槽接线3.1 先定类7个类够用别上来就画几十个类很多同学开写之前喜欢把每个植物做一个类向日葵一个类、豌豆一个类、坚果一个类还没动手光类就建了十几个。实际课设完全没必要。植物该有的共同行为是种下、被吃、死亡差异点只是产阳光还是发射子弹这些差异用一个枚举就能区分。我一般按这个清单组织工程文件职责GameWidget.h/cpp主窗口创建视图、按钮、状态栏GameScene.h/cpp场景核心规则引擎负责阳光数、僵尸刷新、胜负判定Plant.h/cpp植物基类公共行为用 Type 枚举区分品种Sunflower.h/cpp向日葵专属产阳光逻辑Peashooter.h/cpp豌豆射手专属发射逻辑Zombie.h/cpp僵尸移动、掉血、死亡Bullet.h/cpp子弹飞行、碰撞、消失Sun.h/cpp阳光物件下落、点击、计数GameData.h/cpp全局参数表和局内状态GameData 单独拎出来很关键。移动速度、冷却时间、阳光产出间隔这类数字如果随手散落在构造函数里答辩时被问「为什么僵尸走这么慢」只能答「试出来的」。集中到一个类里之后改一个常量就能调整全局难度这个习惯比多写两个功能都加分。3.2 最小可运行骨架main.cpp 与 GameWidget从新建工程到能弹出一个带背景的窗口至少需要三个文件配合。先看入口// main.cpp —— 创建应用与主窗口 #include QApplication #include GameWidget.h int main(int argc, char *argv[]) { QApplication app(argc, argv); // 每个 Qt 程序只能有一个 GameWidget widget; widget.resize(960, 540); // 窗口初始大小 widget.setWindowTitle(植物大战僵尸 - QT课程设计); widget.show(); return app.exec(); // 进入消息循环 }QApplication是程序的生命线所有窗口对象的创建都要放在它之后。resize传的是设备独立像素在大多数显示器上就是逻辑像素。app.exec()会阻塞在这里直到最后一个窗口关闭才返回。主窗口的构造函数里创建场景和视图GameWidget::GameWidget(QWidget *parent) : QWidget(parent) { m_scene new GameScene(this); // 场景承载所有游戏对象 m_view new QGraphicsView(m_scene, this); // 视图只负责渲染 m_scene-setSceneRect(0, 0, 972, 552); // 地图逻辑尺寸 QPixmap bg(:/img/background.png); // 从资源文件加载 m_view-setBackgroundBrush(bg); m_view-setRenderHint(QPainter::Antialiasing, true); m_view-setGeometry(0, 0, 972, 552); m_scene-startGame(); // 初始化僵尸刷新、阳光数 }背景用setBackgroundBrush设置优先从 Qt 资源文件:/路径加载而不是从本地磁盘路径加载这样打包后不会出现图片丢失问题。场景矩形设置成 972x552是为了和窗口显示区域对齐后面网格坐标换算都基于这个矩形。视图的 geometry 和 sceneRect 要一致否则滚动条会冒出来很影响演示效果。3.3 把植物「种下去」放置逻辑与网格坐标换算放置植物是游戏第一个核心交互。点击卡片后鼠标位置先要转成网格坐标再检查阳光数和该格是否已经有植物void GameScene::handleCardClicked(Plant::Type type) { int cost costOf(type); if (m_sun cost) { setStatusText(阳光不足); return; } QPointF scenePos m_view-mapToScene(m_lastMousePos); // 视图坐标转场景坐标 int row (int)(scenePos.y() / kTileHeight); int col (int)(scenePos.x() / kTileWidth); if (row 0 || row kRows || col 0 || col kCols) return; if (m_grid[row][col] ! nullptr) return; // 已有植物 Plant *plant createPlant(type, row, col); m_scene-addItem(plant); m_grid[row][col] plant; m_sun - cost; }这里最容易被忽略的是mapToScene。很多人把QMouseEvent::pos()直接拿来算行列算出来的坐标偏得离谱因为 view 可能有边框、可能有偏移。正确做法是把 view 上的坐标转换成 scene 坐标再做除法取整。m_grid用二维指针数组维护初始化为nullptr。注意createPlant里 new 出来的 Plant 对象由 scene 负责生命周期删除时也要同步把m_grid[row][col]置空否则会出现悬垂指针。3.4 不要把逻辑全塞进 GameWidget一个三天改不完的坏习惯有种代码风格我很熟悉GameWidget 继承 QGraphicsView然后在里面写鼠标事件、写 timer、写僵尸生成规则、写胜负判断所有功能一个类写完头文件一口气四五百行。前期确实快因为不用跳文件。但加入第二个植物品种、加入传送带模式后函数越写越长类内部状态互相牵连改动一个功能要读一遍全局。我的习惯是 GameWidget 只管窗口和视图规则全放在 GameScene 里。比如计时器、僵尸波次、阳光数增减都写在 GameScene植物只知道自己怎么画、怎么动、怎么发信号。View 知道怎么显示Scene 知道规则职责拆开后以后加功能是填空题不是阅读理解。4. 手感是从参数里调出来的游戏循环、碰撞检测与3个关键数值4.1 游戏循环用多少个QTimer一个主循环加专用计时器植物大战僵尸的移动和判定都需要持续驱动。课上最常见的做法是一个 QTimer 管所有事间隔设成 16 毫秒然后在 timeout 里同时更新植物、僵尸、子弹。这样能跑但暂停功能不好做一旦把唯一的 timer 停掉连阳光动画也停了。更可控的做法是把驱动拆成几路void GameScene::initTimers() { m_mainTimer.setInterval(16); // 约 60 帧/秒驱动子弹和僵尸 connect(m_mainTimer, QTimer::timeout, this, GameScene::advanceFrame); m_mainTimer.start(); m_sunTimer.setInterval(8000); // 每 8 秒从天上掉一个阳光 connect(m_sunTimer, QTimer::timeout, this, GameScene::dropSunFromSky); m_sunTimer.start(); m_waveTimer.setInterval(12000); // 每 12 秒刷一波僵尸 connect(m_waveTimer, QTimer::timeout, this, GameScene::spawnZombie); m_waveTimer.start(); }主循环负责每帧移动子弹和僵尸产阳光和刷僵尸用独立计时器这样暂停时只需要停掉主循环界面上的阳光动画还在动观感更自然。要注意的是同一个 QTimer 对象连续调用start()不会产生两个定时器间隔以最后一次调用为准这点很多人误会以为是 start 一次就多了一个定时器。4.2 碰撞检测用 rect 还是 shape命中面积怎么算才不「吃亏」QGraphicsItem 有两个几何概念boundingRect()负责布局和重绘区域shape()负责碰撞检测。如果只重写 boundingRect碰撞范围就是整张贴图的矩形植物看起来明明没被打到却掉血了或者子弹穿过僵尸头顶却算命中。要重写 shape// zombie.cpp —— 把碰撞区缩到脚部范围避免视觉偏差 QRectF Zombie::boundingRect() const { return QRectF(-40, -80, 80, 120); // 显示用的矩形 } QPainterPath Zombie::shape() const { QPainterPath path; path.addRect(-30, -30, 60, 80); // 实际碰撞区比贴图小一圈 return path; }碰撞区比显示矩形小一些是为了手感玩家会觉得「这都打中了还不出血」很沮丧适当缩小碰撞区反而让游戏更宽容。但缩小的尺度要统一我一般取显示矩形的 60% 到 75%。碰撞检测具体调用的地方要指定用 shapevoid Bullet::checkHitZombies(QListZombie * zombies) { for (Zombie *z : zombies) { if (collidesWithItem(z, Qt::IntersectsItemShape)) { z-takeDamage(20); emit bulletHit(this); return; // 一颗子弹只命中一次 } } }collidesWithItem的第二个参数默认为Qt::IntersectsItemBoundingRect不指定就退回矩形判定前面 shape 就白写了。这一条值得重点讲给答辩老师听。4.3 手感三参数移动速度、发射间隔、阳光产出参数是否合理直接决定游戏「好不好玩」。下面是常见配置作为课设起点足够答辩时改一下数值就能演示手感变化僵尸移动 0.3 px/帧按60fps计 豌豆子弹 2.5 px/帧 豌豆射手冷却 1.4 秒 向日葵产阳光 每 12 秒 20 阳光 天上掉阳光 每 8 秒 1 个以 960 宽场景为例僵尸以 0.3 像素每帧移动走完整场大约 53 秒。第一波僵尸在第 12 秒出现从右侧边缘走到最左端玩家有大约 30 秒布防压力曲线是合理的。速度计算要跟帧率解耦。如果直接把僵尸移动量写成固定像素每帧在低配机器上会明显变慢。我习惯把速度定义成「60 帧下的像素值」每帧实际移动量乘一个时间修正系数qreal Zombie::advanceFrame(qreal deltaTimeSec) { setPos(x() - m_speed * deltaTimeSec * 60.0, y()); // 若上一帧耗时20ms则deltaTimeSec0.02 // 移动量 m_speed * 1.2弥补掉帧造成的慢速 }这个修正不是必须的但做了之后用不同性能电脑演示都不会出现「这台机器跑得特别慢」的尴尬场景。4.4 参数表的放置位置把数字收进 GameData我把上面这些数值统一放进 GameData 的常量定义里namespace GameData { constexpr qreal kZombieSpeed 0.3; // 像素/帧 60fps constexpr qreal kBulletSpeed 2.5; constexpr int kSunIntervalMs 8000; constexpr int kPeaShootMs 1400; constexpr int kSunYield 20; }这样调难度只需要改一个文件。答辩演示时当着老师的面把kZombieSpeed从 0.3 改成 0.5重新运行游戏肉眼可见变难这比嘴讲「我调过难度」有说服力得多。5. 基于QT开发植物大战僵尸的常见问题5个翻车现场与排查写这类课设翻车点高度集中我都踩过不止一遍。挑 5 个最常见的逐个拆解每一条都按「现象 → 原因 → 解决」来写。5.1 点击总点到僵尸植物永远选不中现象植物和僵尸叠在一起时鼠标点击几乎没有命中过植物或者点击植物的阳光图标没反应。原因QGraphicsItem 默认按加入场景的顺序来决定谁在上层后加入的僵尸把植物盖住了。另外如果 Plant 类没有重写shape()默认的碰撞区域就是整张贴图透明部分也拦截鼠标事件。解决给图元设置明确的层叠值代码里写上两行zombie-setZValue(5); // 僵尸在植物上方 plant-setZValue(10); // 植物在僵尸上方点击优先命中 sun-setZValue(20); // 阳光在最顶层再在 Plant 里重写shape()把可点击区域缩到贴图的有效部分透明区域就不会再挡鼠标。5.2 越跑越卡最后闪退暂停/继续按钮反复触发的坑现象游戏运行正常但点了暂停再点继续之后僵尸移动速度翻倍几次之后程序卡死。原因多半是暂停按钮的槽函数里写了connect每点一次暂停就多连接一次同一个信号。比如每次暂停都执行connect(m_mainTimer, QTimer::timeout, this, GameScene::advanceFrame)重复 connect 会注册多个槽调用一个 timeout 信号触发多次 advanceFrame于是僵尸一次走两步。还有人会在继续按钮里调start()但暂停时忘了stop()逻辑也会乱。解决暂停和继续共用一个槽函数先停再启void GameScene::togglePause() { m_paused !m_paused; if (m_paused) { m_mainTimer.stop(); // 只停主循环阳光动画继续 } else { m_mainTimer.start(); } }检查代码里有没有在按钮槽中重复 connect 的写法有就删掉连接只在初始化时做一次。5.3 删除子弹或植物时程序崩溃现象子弹命中僵尸后调用scene-removeItem(bullet)随后程序直接崩溃或者删除植物后另一处的僵尸碰撞检测又访问了已删除对象。原因QGraphicsItem 本身不继承 QObject没有deleteLater()可用。直接 delete 之后场景里若还持有这个指针下一帧遍历时就变成访问悬垂指针。网格数组m_grid里存了植物指针删除植物后没有同步置空也会触发同样问题。解决常见的做法是「先移除、后收集、帧末统一删」。子弹命中事件里只标记不直接删除// 在帧结束统一回收避免迭代器失效 QListQGraphicsItem * toDelete; void GameScene::advanceFrame() { // ... 更新逻辑收集 toDelete 中的对象 for (QGraphicsItem *item : toDelete) { removeItem(item); delete item; } toDelete.clear(); }删除植物时记得把m_grid[row][col]置为nullptr否则数组里留下野指针下一帧判断「格子是否占用」就会出错。5.4 子弹「穿」过僵尸碰撞判定和视觉对不上现象动画里子弹明明穿过了僵尸头顶僵尸却不掉血反过来也有子弹差一截就判定命中。原因boundingRect()和shape()各管各的这是最常见的误用场景。如果没重写shape()默认碰撞区域等于 boundingRect这个矩形往往比画面大一圈。另一个隐蔽原因子弹坐标在 scene 坐标僵尸坐标在父 item 坐标两者不够统一导致 collidesWithItem 的距离判断不准。解决先重写 shape再统一坐标系。所有游戏对象都直接 addItem 到 scene 上层不要在 item 上再挂子 item这是 a00 的关系。调试阶段最直接的办法是把 shape 画出来看// 临时在 paint() 里画碰撞区方便校准 painter-setPen(Qt::red); painter-drawPath(shape());调试完删掉这段代码只保留 shape 定义。这样一眼能看出碰撞区和贴图有没有对齐。5.5 窗口拉伸后白屏、画面残余重影现象运行中把窗口拉大背景出现一块未刷新的空白或者植物快速移动时拖出残影。原因QGraphicsView 默认用局部更新模式只重绘变化区域。图元移动过快时旧位置的重绘请求还没处理新位置已经画上背景没有补绘就留下残影。另一个常见来源是drawBackground里做了耗时加载比如每次都从磁盘读图片刷新赶不上。解决设置视图的更新模式和缓存模式m_view-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); m_view-setCacheMode(QGraphicsView::CacheBackground);注意不要在drawBackground里加载图片背景 QPixmap 应该在构造函数里一次性加载好绘图函数只负责 paint不做 IO。课程设计场景下CacheBackground足够了不需要上 OpenGL。6. 答辩前还能做的3处点睛存档、音效与帧率显示核心玩法稳定之后加三个小而独立的功能每项半天内能完成对课程设计完成度的提升是肉眼可见的。第一处是存档。用 QJsonDocument 把阳光数、波次和植物布局序列化到本地文件不需要引入数据库答辩解释起来也直白bool GameScene::saveGame(const QString path) { QJsonObject obj; obj[sun] m_sun; obj[wave] m_wave; QJsonArray plants; for (int row 0; row kRows; row) for (int col 0; col kCols; col) if (m_grid[row][col] ! nullptr) { QJsonObject item; item[row] row; item[col] col; item[type] m_grid[row][col]-typeName(); plants.append(item); } obj[plants] plants; QFile file(path); if (!file.open(QIODevice::WriteOnly)) return false; file.write(QJsonDocument(obj).toJson()); return true; }读档是逆过程QJsonDocument::fromJson之后按 row/col 恢复植物即可。同一局游戏的关键状态全部有了答辩演示时把窗口关掉再打开进度还在这个印象分很实在。第二处是音效。QMediaPlayer 在某些环境依赖系统解码器课设机器上容易出问题。更稳的是 QSoundEffect专门播短 WAVQSoundEffect *shot new QSoundEffect(this); shot-setSource(QUrl(:/audio/shot.wav)); shot-setVolume(0.3f); // 别用默认1.0耳机党会炸 shot-play();音量调成 0.3 左右多个射击音效叠放也不会刺耳。即使演示机器没声卡也不会崩Qt 会静音跳过。第三处是帧率显示。给游戏加一个 FPS 计数器既方便自己调优也是答辩聊性能优化时的素材int frameCount 0; QElapsedTimer fpsTimer; // 在 advanceFrame() 里累计 frameCount; if (fpsTimer.elapsed() 1000) { qreal fps frameCount * 1000.0 / fpsTimer.elapsed(); emit fpsChanged(fps); frameCount 0; fpsTimer.restart(); }把这行接入一个 QLabel 之类的显示控件运行起来能直接看到帧率。改成 100 帧后再观察 FPS 下降幅度还能顺势讲清楚「行为解耦」的必要性。我以前做课设习惯把移动速度、冷却时间这些数字直接写在构造函数里答辩被问「为什么僵尸走这么慢」只能支支吾吾说试出来的。后来养成了把参数收进配置文件、加一个 FPS 计数器、再加一份简单的存档这几件事演示现场能改参数、能看帧率、能断电恢复整个项目才真正从「能跑」变成「能讲」。植物大战僵尸这个题写完不是终点能把每个关键参数为什么是它讲清楚才是期末课程设计想让你练的东西。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?