简介:面向C++程序设计课程期末设计的Qt植物大战僵尸游戏源码包,基于Graphics View框架构建场景与视图,利用面向对象的封装、继承、多态,将植物和僵尸分别抽象为基类,并延伸出太阳花、豌豆射手、坚果、普通僵尸、路障僵尸等具体角色;商店、地图、卡片等板块独立成类,覆盖阳光收集、植物种植、子弹发射、僵尸行进与碰撞判定等完整游戏机制,适合课程设计答辩和游戏编程学习。整个压缩包共111个文件,含25个cpp与25个h源码、21个png与28个gif界面素材、qt工程文件及开发文档pdf,另附演示视频mp4和音效wav,大小约39.43MB。目前已有3828人学习下载。对照开发文档可梳理场景、视图、图形项的关系,从主窗口到每个类的槽函数逐层分析,快速掌握Qt事件循环与碰撞检测的实现;也可作为增加新植物、新僵尸或关卡系统的二次开发框架,完整源代码加开发文档能让期末设计直接落地,无论用于报告撰写、答辩演示还是后续扩展,资料链条都很完整。
1. C++程序设计期末课程设计:用QT重写一版植物大战僵尸
C++期末课程设计拿植物大战僵尸当题目,用QT重写一版,听着像玩具项目,真做起来牵涉的东西一点都不少。这份源码包的核心是 Qt 的 Graphics View 框架,配合封装、继承、多态把植物、僵尸、卡片槽抽象成三条继承线,太阳花、豌豆射手、坚果、土豆雷、樱桃炸弹都在 Plant 派生线上各自实现,难度正好卡在期末课程设计要「有完整功能、有类设计、能演示」的验收点上。它不是那种把图片贴来贴去的小 demo,而是把一局游戏拆成了可扩展的类结构:场景管理、网格种植、碰撞判定、卡片冷却、僵尸啃食,全都有对应的模块。适合两类人:一类是要交 C++ 课程设计、想把游戏做成能跑能演示项目的同学;另一类是学完 Qt 基础、想看 Graphics View 怎么支撑真实游戏逻辑的开发者。下面我按架构、类设计、编译运行、调参验证的顺序把它拆开。
2. 架构选型:Graphics View框架和C++三大特性是怎么在代码里落地的
2.1 Scene / View / Item 三层模型:为什么课程设计不选QWidget绘图
很多课程设计做小游戏会重写paintEvent,在 QWidget 上直接画所有元素。这种写法做俄罗斯方块没问题,一旦场景里同时有阳光、僵尸、子弹、卡片,对象数量上到几十个,重绘区域和点击命中就会变成一团乱麻。这套源码改用 Graphics View 框架,核心思路是三层分离:QGraphicsScene管理场景里的所有 Item,QGraphicsView负责把场景渲染到界面上,QGraphicsItem是每一个可以被选中、移动、刷新、碰撞检测的独立对象。mainwindow.cpp里做的事就是把 View 挂到主窗口中央,构造 Scene,再把植物、僵尸这些 Item 放进 Scene。
三层各自的坐标系统也值得捋一遍:Item 有局部坐标,Scene 是全局坐标,View 是屏幕坐标。鼠标点击植物的时候,先拿到的是 View 坐标,要映射到 Scene 坐标,再由 Map 类换算成种植网格。换算结果记录在二维网格里,保证一个格子只能种一棵植物,这套逻辑在map.cpp里是独立的,没有和绘制代码混在一起,后期改棋盘行列数也只需要动一个文件。
画质相关的配置一般在 View 上做,常见做法是开抗锯齿和设置视口更新模式。setRenderHints(QPainter::Antialiasing)开了之后,豌豆和僵尸的圆角边缘会平滑很多,期末答辩投到屏幕上观感提升明显;setViewportUpdateMode控制画面刷新粒度,对象多的时候用整帧更新会卡,改用局部更新能明显缓解。这些参数都在 mainwindow 的初始化代码里,想调直接改那一行。
2.2 封装、继承、多态:Plant、Zombie、Other三条继承线的分工
摘要里写得很清楚:自定义的三个类都继承自QGraphicsItem,分别是植物基类 Plant、僵尸基类 Zombie、其他基类 Other。Plant 的派生类包括太阳花 SunFlower、豌豆射手 Peashooter、坚果 Wallnut,再算上文件列表里的potatomine.cpp和cherrybomb.cpp,植物至少五条分支;Zombie 的派生类有普通僵尸 BasicZombie、路障僵尸 ConeZombie 这类;Other 下面挂着商店 Shop(卡片槽)、地图 Map、卡片 Card,另外button.cpp、shovel.cpp也都归在这条线附近。
这套派生结构把游戏对象分成了独立分支,每个派生类只实现自己差异化的部分。多态的作用在帧循环里体现得最直接:Scene 里的 Item 都是基类指针,调用update()、advance()时自动进入各个派生类的实现,不用在循环里写一堆if (type == 1)判断。QGraphicsItem本身要求子类必须实现boundingRect()和paint()这两个纯虚函数,植物画成什么样、僵尸画成什么样,全部由各自的 paint 决定。
| 源文件 | 对应类 / 模块 | 职责 |
|---|---|---|
mainwindow.cpp | MainWindow | 装配 Scene、View、定时器、主循环 |
card.cpp | Card | 卡片绘制、冷却状态、点击响应 |
shop.cpp | Shop | 卡片槽管理、阳光消耗 |
map.cpp | Map | 地块网格、坐标换算、格子占用 |
zombie.cpp | Zombie 基类 | 移动、啃食、受击通用逻辑 |
basiczombie.cpp | BasicZombie | 普通僵尸的外观和参数 |
potatomine.cpp | PotatoMine | 土豆雷的武装、爆炸逻辑 |
cherrybomb.cpp | CherryBomb | 樱桃炸弹的延时引爆 |
button.cpp | Button | 开始、暂停等控制按钮 |
shovel.cpp | Shovel | 铲除已种植的植物 |
这个表基本就是源码包的目录地图,下载后先对照这个表把文件过一遍,读代码的效率会高很多。
2.3 游戏循环:QTimer驱动和信号槽的配合方式
整套游戏没有引入物理引擎,游戏推进靠定时器驱动,这是 Qt 小游戏最常见的做法。MainWindow 里建一个QTimer,把timeout信号连到 Scene 的advance()方法上,每帧推进所有 Item。太阳花产阳光、豌豆射子弹、僵尸移动,都挂在这同一个帧循环里,而不是每个对象单独开线程——游戏对象数量不大,单线程主循环完全够用,而且能避免大量线程同步问题。
卡片这块走的是信号槽:玩家点击卡片后,卡片发出选种信号,Shop 把当前状态切到「种植模式」,然后再接收鼠标点击事件完成种植。这个交互链如果直接写在 mousePressEvent 里,后期加铲子、加樱桃炸弹都会改到想哭;拆成信号槽之后,卡片、商店、地块三者解耦,新增一种植物只需要注册新的签名和对应的派生类。
定时器还有一个容易忽略的细节:冷却计时和阳光产出不能用同一个小间隔硬写,否则调游戏节奏时要改好几个地方。常见的做法是把冷却计数放到 Card 内部,阳光产出放到太阳花自己的 tick 里,这样数值调整限定在局部类中,不会牵连全局。
3. 核心类拆解:Plant、Zombie、Shop三线设计,以及网格种植和碰撞判断
3.1 Plant一条线:构造函数传参、冷却与攻击判定的实现
Plant 这条线是所有派生类里数量最多的,封装的好处在这里体现得很明显。基类把公共状态都收进来:血量、阳光消耗、冷却时间、受击后的扣除逻辑。下面的骨架代码展示了这类结构长什么样:
class Plant : public QGraphicsItem { public: Plant(int hp, int cost, int cooldown, QGraphicsItem *parent = nullptr) : QGraphicsItem(parent), m_hp(hp), m_cost(cost), m_cooldown(cooldown) {} int cost() const { return m_cost; } bool isCooling() const { return m_cooldown > 0; } int hp() const { return m_hp; } void takeDamage(int damage) { m_hp -= damage; if (m_hp <= 0) { setVisible(false); } } void tick() { if (m_cooldown > 0) { --m_cooldown; } } QRectF boundingRect() const override { // 需要与实际绘制尺寸匹配,不要随意放大 return QRectF(0, 0, 60, 80); } protected: int m_hp; int m_cost; int m_cooldown; };这里有两个细节值得注意。第一,takeDamage里血量扣到零后只做了setVisible(false),没有立刻从 Scene 里删除,这是为了避免在帧循环中间删除对象导致迭代器失效,真正的清理放到下一帧或deleteLater(),是 Qt 游戏里一个比较务实的小技巧。第二,boundingRect()返回的矩形必须和绘制内容一致,如果画的是 60×80 的植物,这里返回值写小了,点击命中区域就会缩水,出现「鼠标点中了植物却选不中」的玄学问题。
豌豆射手、土豆雷、樱桃炸弹的差异主要在行为上,构造函数负责把参数传进来,paint()负责画各自的外观。土豆雷比较特殊,它有一个「未武装」状态:种下去的前几秒不能引爆,要等计时结束切换到武装状态,之后僵尸踩到才爆炸。实现上就是增加一个布尔状态或枚举,在advance()里轮询。樱桃炸弹则是种植后延时引爆,可以用一次性计时器触发爆炸动画,再把爆炸范围内的僵尸都扣一遍血。这些分支都靠多态接口收敛到基类指针上,卡片槽不用关心具体是谁。
3.2 Zombie一条线:移动、啃食、死亡状态切换
僵尸的行为比植物更线性,但状态切换也要认真处理。普通僵尸和路障僵尸的差别主要体现在参数上:路障僵尸血量更高、移动速度略慢、外观多一个路障。
僵尸的核心逻辑是三种状态:行走、啃食、死亡。行走时每帧向左移动固定距离;走到植物面前切换成啃食,对植物持续造成伤害;植物血量归零后,僵尸回到行走状态继续前进。下面是一段骨架代码:
enum class ZombieState { Walking, Eating, Dying }; void BasicZombie::advance(int phase) { if (!phase) { return; // phase == 0 时只做位置预更新,避免一帧跑两次 } switch (m_state) { case ZombieState::Walking: if (findPlantInFront()) { m_state = ZombieState::Eating; m_targetPlant = findPlantInFront(); } else { setX(x() - m_speed); } break; case ZombieState::Eating: if (!m_targetPlant || m_targetPlant->hp() <= 0) { m_state = ZombieState::Walking; m_targetPlant = nullptr; } else { m_targetPlant->takeDamage(m_dps); } break; case ZombieState::Dying: // 播放死亡动画后调 deleteLater() break; } }advance(int phase)的 phase 参数是 Qt 的机制:一帧内会调用两次,第一次 phase 为 0 做逻辑更新,第二次 phase 为 1 做绘制更新,逻辑判断加个if (!phase) return;可以避免状态被处理两遍。
僵尸找目标的方式不一定要全场景遍历,常见的做法是只检查当前行前方固定距离内有没有植物,这个查找逻辑放在你自己的findPlantInFront()里,可以用 Map 的网格数据直接判断「这一行前方格子是否被占用」,效率比collidingItems更高。路障僵尸的血量拆分是一个可选技巧:把路障看作额外一层护甲,优先扣路障血量,路障被打掉后再扣本体,配合paint()里判断当前阶段把路障画出来,视觉反馈会明显很多。
3.3 Shop、Map、Card和Shovel:种植交互的数据层与表现层分离
种植交互是这版游戏里最容易写出 bug 的部分。完整流程分四步:点击卡片、扣阳光、跟鼠标移动、点击地块落子。shop.cpp负责第一步的扣费和卡片冷却,card.cpp画卡片的形状和状态,map.cpp负责最后一步的网格换算。
网格换算的核心在于把自由坐标吸附到格子中心,公式大致是这样:
int col = qBound(0, (scenePos.x() - gridOriginX) / gridCellWidth, gridCols - 1); int row = qBound(0, (scenePos.y() - gridOriginY) / gridCellHeight, gridRows - 1); if (gridOccupied[row][col]) { return; // 格子已被占用,不允许重复种植 }gridOriginX是棋盘左上角的起始坐标,gridCellWidth是每格宽度。qBound的作用是把越界坐标限制在合法范围内,防止玩家点到棋盘外把索引算成负数,导致数组越界崩溃。
铲子的实现有两种思路。一种是点击铲子后进入铲除模式,再点击地块上已有的植物;另一种是直接检测鼠标点击位置是否有植物。文件列表里有独立的shovel.cpp,说明它作为工具类被单独抽出来了。和种植流程相比,铲除逻辑要注意顺序:先把植物从网格数组中移除,再调 Scene 的removeItem,最后deleteLater,顺序反了容易留下悬空指针。
地图这块还有个小坑:阳光值归零或不足时,卡片应该置灰且不可点击。判断点放在 Shop 的点击入口里,不要在 Card 内部各自检查,否则新增一种植物就要改一次卡片逻辑。
4. 运行与避坑:Qt环境配置、编译报错和运行时崩溃的排查记录
4.1 环境对齐:Qt版本、编译器、qmake三件套
拿到源码包先别急着双击 .pro,先确认本机 Qt 环境。期末课程设计里最常见的翻车是把代码拷到另一台机器上,Qt 版本从 5.x 换成 6.x,库路径和模块名称对不上,编译报一堆错。建议先用 Qt 5.15 LTS 或 6.x 里你熟悉的一个版本,关键是工程文件和宿主环境要一致。
.qpro 文件本身不复杂,核心就是声明模块和列出源文件:
QT += core gui widgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = PlantsVsZombies TEMPLATE = app # 把解压目录里实际存在的 .cpp / .h 追加到下面两组变量里 SOURCES += main.cpp \ mainwindow.cpp card.cpp shop.cpp map.cpp \ zombie.cpp basiczombie.cpp potatomine.cpp \ cherrybomb.cpp button.cpp shovel.cpp HEADERS += mainwindow.h card.h shop.h map.h \ zombie.h basiczombie.h potatomine.h \ cherrybomb.h button.h shovel.h RESOURCES += res.qrcgreaterThan(QT_MAJOR_VERSION, 4)这行是在 Qt5 及以上自动追加 widgets 模块,老工程从 Qt4 迁移时很关键,删掉会导致 Qt5 下找不到QWidget头文件。RESOURCES对应资源文件,如果源码包里有图片素材,需要确认res.qrc里的路径实际存在,否则运行时会找不到图片,植物和僵尸全是空白方块。
构建方式有两种:习惯命令行的用qmake && mingw32-make,Windows 下 MSVC 则用nmake;想省事就直接用 Qt Creator 打开 .pro 文件编译。如果你平时习惯在 VS Code 里写 C/C++,调试 Qt 项目还是建议切回 Qt Creator,至少它能自动识别 .pro 里的源文件列表和 Qt 的 include 路径,少踩一堆环境配置的坑。
4.2 编译期报错:fatal: cannot mix incompatible Qt library
- 现象:编译过程正常,链接阶段报错,错误信息类似
fatal: cannot mix incompatible Qt library (version 0x50601) with this library,代码本身没有任何语法问题。 - 原因:这句话不是代码错误,是库冲突。工程或系统中的某个库编译时用的是 Qt 5.6.1 的头文件,而链接时却混入了其他版本的 Qt 库,版本不匹配直接拒绝链接。常见场景是电脑上装了多个 Qt 版本,PATH 环境变量或 qmake 指向不一致。
- 解决:先
qmake -v看当前 qmake 指向哪个版本,然后彻底清理编译缓存,把 build 目录和.qmake.stash、Makefile全删掉,重新 qmake。如果还报错,检查 PATH 里是否混入了其他 Qt 版本的 bin 路径,把不需要的版本从 PATH 里拿掉,只保留当前项目用的那个。
4.3 运行时报错:qt.qpa.plugin Could not find the Qt platform plugin "linuxfb"
- 现象:在 Linux 或嵌入式板卡上运行编译好的可执行文件,终端输出
qt.qpa.plugin: could not find the qt platform plugin "linuxfb",程序直接退出,窗口根本出不来。 - 原因:Qt 在 Linux 下靠平台插件和显示服务器通信,
linuxfb是嵌入式平台插件。桌面 Linux 上如果没安装 xcb 插件,或者环境变量QT_QPA_PLATFORM被误设成了linuxfb,Qt 找不到对应插件就罢工。 - 解决:桌面环境一般不需要 linuxfb。执行
export QT_QPA_PLATFORM=xcb或直接取消这个环境变量再运行。如果提示缺少 xcb,装对应发行版的 Qt xcb 插件包。只有当真在跑 framebuffer 设备时才用 linuxfb,而且要先确认插件二进制实际存在于 Qt 的plugins/platforms目录里。
4.4 运行崩溃:access violation 0xC0000005 与 QGraphicsItem 野指针
- 现象:游戏运行中突然崩溃,调试器停在
QGraphicsScene::removeItem附近,错误代码0xC0000005,也就是访问违例——读写了已释放的内存。 - 原因:游戏对象在帧循环里被删除时,其他地方还持有指向它的指针。典型场景是:僵尸啃死了植物,植物把自己 delete 了,下一帧僵尸还要通过
m_targetPlant访问这个对象的hp(),访问到的已经是悬空指针。 - 解决:删除对象前必须先
scene->removeItem(item),再deleteLater(),让 Qt 在事件循环安全点统一释放。持有目标的类在目标失效时要把指针置空。我的习惯是在基类里加一个isInScene()或isAlive()判断,访问前先检查,别指望指针自己变安全。
4.5 碰撞误判:土豆雷该炸没炸,樱桃炸弹炸空
- 现象:土豆雷埋在格子里,僵尸明明踩上去了,它却迟迟不爆炸;樱桃炸弹放下来几秒后爆炸,范围边缘的僵尸却完好无损。
- 原因:碰撞判定用的是
boundingRect()的矩形区域,矩形比实际物体大或小都会有偏差。土豆雷的问题是触发半径设得过小,或者僵尸移动步长太大,一帧内直接从触发区上方穿过去了,帧循环根本没检测到相交。樱桃炸弹的问题则往往出在爆炸范围计算上,如果用矩形判定圆形爆炸区域,四个角的僵尸会被误判为在范围内。 - 解决:碰撞筛选用
shape()或自定义圆形区域,不要宽泛地用boundingRect()做精确判定。僵尸的移动要做步进检测,保存上一帧位置和当前位置,两个位置之间的线段再和触发区做相交判断,就不会漏掉高速移动的物体。爆炸范围用「中心点距离」判断,半径内算命中,比矩形判定准得多。
5. 进阶调优:平衡参数、状态机重构和一局试玩的验证闭环
前几章把这套源码的结构和坑都过了一遍,剩下的问题是怎么把一局游戏调得真正能玩,而不是能跑就行。期末答辩时,评委看的就是手感——阳光产出节奏、僵尸进攻密度、植物强度是否匹配,代码结构反而不是最直观的。
植物和僵尸的数值最后都会落到构造函数那几个参数上。下面是一组参考值,你可以按自己的感觉改:向日葵产阳光间隔约 12 秒、每次 50 阳光,豌豆射手单发伤害 20、攻速 1.5 秒,普通僵尸血量 200、速度约 0.4 格/秒,路障僵尸血量 400、速度略慢。这些数值没有标准答案,调整的核心原则是「前期让玩家有余力种豌豆,中期感受到压力,后期靠樱桃炸弹翻盘」。如果觉得阳光永远够用,就调高植物价格或缩短僵尸出兵间隔;如果觉得一波都撑不住,就把甲板第 1 行设置为安全区,直接在代码里加一个出生保护判定。
一个很实用的重构招数是把散落的僵尸行为判断收敛成状态机,前面 3.2 节的ZombieState枚举就是雏形。当分支越来越多时,switch比一堆if else清晰得多,每个 case 只做一件事,状态迁移路径明确,出 bug 时看日志就能定位。我通常会在每个状态迁移处打一行 qDebug 输出,比如Walking->Eating,跑一局下来能完整复盘僵尸的行为轨迹,比断点调试高效。
验证和测试也不要全靠肉眼。C++ 的随机数如果不固定种子,每一局的僵尸波次都不一样,很难评估一次改动到底有没有效果。在测试阶段用固定随机种子初始化std::mt19937,改动前后用同一种子各跑一遍,才能确定差异来自数值调整而不是运气。帧率验证则用QElapsedTimer包住advance()循环,超过 33 毫秒说明当前帧有性能瓶颈,再考虑优化绘制或减少 Item 数量。
这里说一个我自己的翻车经历,权当后车之鉴:有一版我把向日葵的产阳光间隔从 12 秒改成 2 秒来测卡手问题,结果满场阳光把场景里的 Item 堆到上千,帧率从 40 帧掉到 12 帧,最终直接卡死。从那以后我每次调数值都会在开发文档里先记一笔旧值和新值,改完只跑两分钟验证就拉回真实节奏,绝不让测试参数污染正常对局。这套源码的完整工程和开发文档都在同一个包里,下载后先按第 4 章的流程把环境对齐,再对照第 3 章的类图读代码,一次跑起来的概率很高。希望帮到你。
本文还有配套的精品资源,点击获取