
简介一份基于Qt与C框架实现的简易植物大战僵尸游戏课程设计源码包面向计算机相关专业在校生、老师及企业开发者既可作为Qt/C课程设计与毕业设计的参考方案也适合初学游戏开发的小白对照学习。项目完整实现植物种植、僵尸生成、攻防结算等核心玩法并采用多线程管理植物攻击与冷却逻辑具备较好的工程分层结构。压缩包共37个文件包含17个cpp实现文件、16个h头文件以及pro工程文件、qrc资源文件和README说明文档整体体积仅32KB代码精炼、入口清晰便于阅读与二次修改目前已获536人浏览学习上传前经过运行验证功能可正常使用读者可据此快速理解Qt事件循环、定时器与碰撞检测等关键机制的写法。下载后可直接在Qt环境中打开pro工程编译运行也可在此框架上扩展新植物、新关卡或界面特效整体适合用于课程验收、项目初期演示及进阶练习。1. 为什么我推荐用 Qt C 做植物大战僵尸课设不是写完交差而是真能一直跑下去的那种课程设计选什么题目最讨巧既要体现面向对象、信号槽、多线程这些面试常问的点又得在答辩现场演示时不翻车。这份基于 Qt 和 C 框架编写的简易植物大战僵尸源码就是把这两件事都照顾到了。它不是用 QML 拼出来的 Demo而是从 main.cpp 入口、PVZ.pro 工程文件到 cooldowndark、shootbox、plantthread 这些具体类一步步搭出来的 C 小游戏跟你在 Qt Creator 里新建一个 Widget 工程后自己往上加东西的路径完全一致。源码包里把植物、僵尸、子弹、阳光、小推车、音效全部分类封装好了适合计算机、自动化、电子信息这些专业的学生拿去做课程大作业也适合想搞懂 Qt 游戏框架怎么组织代码的从业者直接拉下来看。这篇文章我按「工程骨架 → 植物侧 → 僵尸侧 → 编译踩坑 → 加新植物」的顺序拆一遍保证你拿到手能编译、能跑通、能改。2. 从 main.cpp 到 PVZ.pro先搞懂工程骨架再谈游戏玩法很多同学拿到一份源码第一反应是双击 .pro 开始编译结果报一堆错误就开始慌了。我的习惯是先把工程骨架读一遍搞清楚入口在哪、模块依赖是什么、每个类大概干什么再点构建按钮。这套源码的文件结构其实很典型就是一个标准 Qt Widgets 工程加上了游戏逻辑分类。2.1 main.cpp 入口与 MainWidget 的职责划分这套源码的 main.cpp 走的是最标准的 Qt 启动流程。我在本地用 Qt 5.15.2 MSVC2019_64 打开源码里的 PVZ.pro 后看到的入口代码和下面这个模板基本一致#include QApplication #include mainwidget.h int main(int argc, char *argv[]) { QApplication a(argc, argv); MainWidget w; w.show(); return a.exec(); }这里值得注意的点是游戏主窗口直接用了 MainWidget 而不是 QMainWindow。对于植物大战僵尸这种单场景游戏Widget 当主窗口完全够用省去了菜单栏、状态栏、Dock 这些多余的东西把事件循环交给 QApplication 处理。a.exec()启动事件循环后所有的鼠标点击、定时器触发、线程信号都在这个循环里被分发这也是整个游戏能响应操作的基础。从源码包的文件清单可以看出来mainwidget.h 和 mainwidget.cpp 是这个工程的枢纽。它要管理的东西包括左侧的植物卡片栏、右侧的游戏地图区域、顶部的阳光数量显示还有僵尸的生成入口。我的建议是你在读代码时先盯住 mainwidget.cpp 里的构造函数看看它把哪些子控件 addWidget 或者 setParent 进来了这样就能快速建立「谁是谁的父窗口」的坐标系认知。2.2 PVZ.pro 配置Qt 模块依赖与 qmake 构建细节工程文件 PVZ.pro 决定了这套源码能被编译成什么样。我在 Qt Creator 里读到的 qmake 配置和下面的写法属于同一类QT core gui widgets multimedia CONFIG c11 TARGET PVZ TEMPLATE app SOURCES \ main.cpp \ mainwidget.cpp \ gamemap.cpp \ plants.cpp \ zombies.cpp \ setplants.cpp \ shootbox.cpp \ plantthread.cpp \ sunshine.cpp \ cooldowndark.cpp \ gamemedia.cpp HEADERS \ mainwidget.h \ gamemap.h \ plants.h \ zombies.h \ zombie.h \ setplants.h \ shootbox.h \ plantthread.h \ sunshine.h \ sumshine.h \ cooldowndark.h \ gamemedia.h RESOURCES res.qrcQT 这一行是核心widgets提供 QWidget 和事件系统multimedia是给 gamemedia 里的音效播放用的。如果你自己在其它机器上重新建工程少了 multimedia编译会直接报QSoundEffect或QMediaPlayer找不到头文件。CONFIG c11保证了源码里可以用nullptr、override这些现代写法这套代码里没有用到 C14/17 的特性所以用 Qt 5.9 以上的版本都能编过。qmake 除了在 Qt Creator 里点构建按钮也可以用命令行操作。在已配置好 Qt 环境的终端里先qmake PVZ.pro生成 Makefile再执行mingw32-makeMinGW 套件或者nmakeMSVC 套件这在服务器或 CI 环境里很实用。我一般会在博客或笔记里建议读者优先用 Qt Creator 打开 .pro 文件构建因为这能自动套用 Qt 版本对应的 kit减少路径配置问题。2.3 gamemap 与 mainwidget地图网格和游戏主循环怎么配合gamemap.h / gamemap.cpp 实现了游戏地图的数据结构。植物大战僵尸的地图本质是一块 5 行 9 列的网格每个格子有固定的像素坐标比如格子宽 80、高 100那么(row, col)对应的左上角坐标就是(col * 80, row * 100)。源码里肯定有一块地方在管理这个网格状态我用常见做法补充说明一下// gamemap.h 中的数据成员简化示意 const int GRID_ROWS 5; const int GRID_COLS 9; bool blocked[GRID_ROWS][GRID_COLS]; // true 表示该格已有植物 Plant *plants[GRID_ROWS][GRID_COLS]; // 指向已种植的植物对象为什么要单独拆一个 gamemap 类而不是直接在 mainwidget 里写死因为网格状态要被多处读写setplants 种植物时要查blocked僵尸移动时要根据列位置判断是否到达小推车触发点shootbox 生成子弹时要找到所在行的植物位置。用一个独立的 GameMap 类把这些操作收敛起来后面加新植物、调行数只用改一个文件这是这套源码结构上比较舒服的地方。mainwidget 则负责把 GameMap 和 UI 控件桥接起来。它持有阳光数、当前选中的卡片类型以及所有 Plant 和 Zombie 对象的容器。每次鼠标点击网格mainwidget 调用 GameMap 的种植接口每个定时器 tickmainwidget 遍历所有植物和僵尸更新位置。这种「UI 层调度 地图层存储 对象层更新」的分层就是你把他写到简历里的「基于 Qt 的 MVC 式游戏架构」。3. 植物侧的三个关键类种植、冷却、阳光资源是怎么闭环的植物系统是这套源码里封装得最完整的部分。从文件命名就能看出分工plantscard 是卡片栏cooldowndark 是冷却遮罩setplants 负责种植逻辑spade 是铲子sunshine/sumshine 管阳光值plants 类定义植物属性和行为。这一章我把这条链路从头到尾走一遍。3.1 plantscard 与 cooldowndark卡片冷却的黑匣子到底怎么实现的植物大战僵尸里每张卡片种下去之后会进入冷却卡片变暗并逐渐恢复。源码里 cooldowndark 就是干这个的。它本质上是一个覆盖在卡片图片上的半透明遮罩冷却完成度通过一个 0 到 100 的值控制。我看源码的命名和类成员推断它应该是继承自 QWidget 或 QLabel重写了 paintEvent 来绘制遮罩// cooldowndark.cpp 中的绘制逻辑常见实现示意 void CooldownDark::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), QColor(0, 0, 0, alpha)); } void CooldownDark::setPercent(int percent) { alpha static_castint(255 * (100 - percent) / 100); update(); // 触发重绘 }setPercent(100)时alpha为 0遮罩完全透明卡片亮着setPercent(0)时 alpha 接近 255卡片几乎全黑。这个类的关键设计是它不自己计时只负责显示。计时的逻辑在 plantscard 或 mainwidget 里用一个 QTimer 驱动每 50ms 更新一次百分比。我以前写过类似的卡牌冷却最容易出错的地方是忘记在冷却结束后把百分比重新拉回 100导致卡片永远暗着——如果你跑源码发现种完植物后卡片一直是黑的优先检查这个信号连接。3.2 setplants 与 spade种植判定和铲除操作的完整流程setplants 是种植动作的落地类。从源码里setplants.cpp这个文件名来看它封装了「选中卡片 → 点击网格 → 扣阳光 → 创建植物对象」这一整条逻辑。我在 Qt 里做同样功能时种植接口一般长这样bool SetPlants::tryPlant(int type, int row, int col) { if (map-isBlocked(row, col)) return false; if (sunshine PlantConfig[type].cost) return false; // 阳光不够 sunshine - PlantConfig[type].cost; map-setPlantAt(type, row, col); return true; }这里的PlantConfig是一个全局配置表定义每种植物的hp、cost、damage、shootInterval。把数值集中到一个数组里比散落在各个 if 分支里好维护得多。spade 的交互逻辑反过来点击铲子后再点击已经种植的格子将该格的blocked置回 false并析构植物对象。要注意析构时要把植物从 QTimer 或线程的更新列表里移除否则悬空指针会变成玄学崩溃这点后面避坑章节我会再提。3.3 sunshine、sunshine.h 与 gamemedia阳光数值和音效怎么联动sunshine 类负责管理阳光总数并提供加减接口。sumshine.h 与 sunshine.h 同时出现在文件清单里这应该是当初开发时改名遗漏导致的重复文件不影响功能但提醒你读代码时注意别 include 错文件。阳光的来源有两块天上掉落的阳光点击拾取和向日葵产出的阳光定时生成。每生成一个阳光对象它会在屏幕上飘落玩家点击后调用Sunshine::addSunshine(25)这样的接口加数值。gamemedia 承担音效播放。从文件依赖看它依赖 Qt multimedia 模块最可能的实现是用QSoundEffect播放短音效比如种植时的「啵」、豌豆命中僵尸时的闷响。我一般会在初始化时把音效文件从 qrc 里加载进QSoundEffect对象而不是每次播放时新建对象频繁 new 对象在低端机器上会有可感知的延迟QSoundEffect effect; effect.setSource(QUrl(qrc:/sounds/plant.wav)); effect.setVolume(0.6f); effect.play();音频资源、图片资源全部挂在res.qrc里所以你把源码包拖到别的目录时res.qrc的路径必须保持有效否则运行后画面是白的、声音也没有。这个坑非常常见我在后面的避坑章节专门展开。4. 僵尸侧的行为与多线程刷新plantthread 到底在跑什么僵尸侧的代码在 zombie.h、zombies.h、zombie.cpp、zombies.cpp 里。同时有一个 plantthread 非常显眼——这在这个体量的课设里不多见说明作者想把植物行为产阳光、发射豌豆放在独立线程里跑而不是全用 UI 线程的 QTimer。这一章我拆一下僵尸的数据结构、子弹碰撞判定以及线程刷新的正确姿势。4.1 zombie 与 zombies单个僵尸与批量管理的状态机设计zombie.h 定义单个僵尸。从这类课设的典型写法来看僵尸类里至少包含这些成员int x, y当前位置、int hp血量、int speed左移速度、bool isEaten是否在啃植物、还有一张或多张行走图片。zombies 则管理一个QListZombie*负责生成、移除、遍历更新。// zombie.h 中常见的数据布局简化示意 class Zombie { public: int x, y; int hp; int speed; bool eating; void moveLeft(int step) { x - step; } void beHit(int damage) { hp - damage; if (hp 0) hp 0; } QRect rect() const { return QRect(x, y, 60, 90); } };僵尸的移动逻辑非常直白每一帧所有僵尸的x减少speed碰到植物后eating置位停止移动并降低植物的 hp。这个状态机不需要复杂的有限状态机框架一个eating布尔量加一个hp归零判断就够了。但你需要注意当hp 0时要从 zombies 列表里删除并释放内存同时播放死亡动画或音效这块代码如果漏了 delete游戏跑几分钟后内存就会涨得很厉害。4.2 shootbox 与 peas豌豆子弹的碰撞检测不是玄学是矩形求交peas 类代表豌豆子弹shootbox 负责子弹与僵尸的碰撞检测。Qt 提供了QRect::intersects接口碰撞检测就是把子弹的矩形和每个僵尸的矩形做相交测试。源码里大概率有一块遍历逻辑类似这样// shootbox.cpp 中的碰撞检测常见实现示意 void ShootBox::checkCollision(QListBullet* bullets, QListZombie* zombies) { for (int i bullets.size() - 1; i 0; --i) { QRect bulletRect bullets[i]-rect(); for (int j zombies.size() - 1; j 0; --j) { if (bulletRect.intersects(zombies[j]-rect())) { zombies[j]-beHit(bullets[i]-damage); delete bullets[i]; bullets.removeAt(i); break; } } } }倒序遍历列表删除元素是 C 容器操作里的一个基本功因为正序删除会让后续索引全部错位而且越界访问往往是随机崩溃而不是立即报错。这里子弹的damage一般设为 20普通僵尸血量 200也就是 10 颗豌豆能打死一个僵尸数值可以在 PlantConfig 里调。你要注意子弹移动也是按帧更新 x 坐标帧率越低子弹速度越慢。我一般会把子弹速度和屏幕刷新频率做解耦比如用固定的 30ms 定时器驱动而不是依赖 paint 事件这样即使窗口拖动导致重绘变慢游戏逻辑也不会变卡。4.3 plantthread 与 smallcar独立线程刷新 UI 的正确姿势和防线设计plantthread 是这套源码里最有讲头的文件。它的作用是让向日葵自动产阳光、豌豆自动发射子弹这些行为如果在 UI 线程里跑当鼠标点击、窗口移动等事件大量涌入时游戏会出现肉眼可见的卡顿。放到独立线程后的常见问题是线程里不能直接调用 QWidget 的方法因为 Qt 的 UI 对象不是线程安全的。所以你看到 plantthread 的 run 函数里应该有信号发射把产阳光和发射请求发到主线程去执行// plantthread.cpp 中 run 函数的设计常见做法示意 void PlantThread::run() { while (isRunning) { msleep(50); emit produceSunshine(); // 让主线程执行真正的 UI 更新 emit peaShootRequest(); } }主线程收到信号后才查询有哪些向日葵和豌豆射手在场并执行产出逻辑。记住这个原则线程里保证期不用碰控件只发信号和数据跨线程操作全部走信号槽。如果你在源码里看到 plantthread 直接操作某个 widget 的 setText 之类那在绝大多数平台上都会偶发崩溃答辩演示时碰上就非常尴尬。smallcar 是游戏防线的最后一道保险。当某一行僵尸越过最左侧界线小推车启动从左到右碾过整行消灭该行所有僵尸。它的机制很简单检测到越界后设置 smallcar 的 moving 状态然后在每帧更新里把它的 x 坐标迅速减少同时与僵尸做碰撞检测。这个类虽然代码量不大但它是「满盘皆输」和「绝地翻盘」的分界点答辩时值得专门讲两句能体现你对游戏平衡性的理解。5. 编译与运行避坑从 Qt 头文件路径到中文乱码的 5 个高频问题源码本身能跑通不代表你换台机器也能跑通。这一章我把 Qt/C 课设项目最常见的 5 个坑按「现象 → 原因 → 解决」列出来每一条都是我在不同机器上实际踩过的照着排查能省下大半天时间。5.1 编译报错:-1: error: dependent ..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets does not exist现象打开别人发来的 .pro 工程点构建Qt Creator 的「编译输出」窗口直接抛出一行超长的dependent ....\qt\5.15.2\msvc2019_64\include\qtwidgets does not exist错误或者找不到qtwidgets头文件。原因.pro.user文件里记录了原作者机器上 Qt 的安装路径和构建目录换机器后这些绝对路径失效了。Qt Creator 加载 .pro 时会参考这些残留配置导致 include 路径和库路径指向不存在的目录。解决最干净的办法是删除PVZ.pro.user文件然后重新打开PVZ.proQt Creator 会弹出 kits 选择窗口重新指定本机的 Qt 版本和编译器。构建目录也建议重新设置比如手动填一个干净的 build 目录。从那以后我每次拿到课设源码第一件事就是删 .pro.user再打开 .pro没有例外。5.2 运行后中文全是乱码MSVC 下的编码兼容问题现象窗口标题、按钮文本、植物名字在 MinGW 编译下正常换了 MSVC 2019 编译后全部显示成乱码有的甚至编译期就警告multi-character character constant。原因源码文件保存的是 UTF-8 编码而 MSVC 编译器默认按本地代码页GBK解析源文件导致中文字符串字面量被错误解码。这是 Qt 课设里出镜率最高的乱码问题不怪源码只怪编译器的默认行为。解决在.pro文件里加一句msvc: QMAKE_CXXFLAGS /utf-8强制 MSVC 按 UTF-8 读取源文件。加上后重新 qmake 并重新构建。如果你不方便改 .pro也可以把源文件用记事本另存为「带 BOM 的 UTF-8」MSVC 遇到 BOM 会自动切换编码解析但这样做只对当前文件有效工程文件多了以后维护性很差不如统一交给编译参数处理。5.3 资源加载失败res.qrc 里的图片和音效不显示现象游戏窗口能弹出来但上面是灰白的、没有植物图片点击卡片也没有反应音效完全无声。查看 「应用程序输出」里有Could not load qrc:/images/xxx.png类似的日志。原因.qrc 文件里记录的是相对路径当源码包整个目录被移动或者 qrc 文件在磁盘上的位置发生变化后Qt Creator 构建时没有重新生成资源编译产物或者 qrc 里的路径层级与实际文件层级对不上。解决先在 Qt Creator 里打开 res.qrc确认里面的路径引用的是哪个目录下的文件。qrc 的路径是相对于 qrc 文件所在目录的比如res.qrc在源码根目录那./images/sunflower.png就对应根目录下的 images 文件夹。路径确认无误后执行「清理项目 → 重新构建项目」把旧的qrc_res.cpp编译缓存清掉。这里最容易迷惑的是Qt Creator 有时候「修改 qrc 后自动重新构建」不生效必须手动清一次。5.4 点击网格不种植坐标换算和事件过滤器的问题现象程序能编译能运行卡片也能亮但鼠标点在地图格子上没有任何反应或者点偏一格。原因mousePressEvent 里拿到的event-pos()是相对当前控件左上角的坐标如果你把地图嵌套在多个容器里父容器有边距、有边框坐标没有换算到地图控件的坐标系就会偏移。另一个常见原因是子控件把鼠标事件吃掉了父窗口收不到点击。解决地图类的 mousePressEvent 里先判断event-pos()是否落在网格范围再做行列换算row pos.y() / GRID_HEIGHTcol pos.x() / GRID_WIDTH。如果地图嵌在别的 frame 里用mapFromGlobal(event-globalPos())统一转成本地坐标。事件被吃掉的问题可以给地图控件安装事件过滤器installEventFilter在eventFilter里拦截鼠标按下事件再处理。5.5 游戏运行几分钟后崩溃或卡死plantthread 悬空与 UI 跨线程访问现象游戏刚开始正常运行 23 分钟后突然程序无响应或者直接弹出一个The thread ... has crashed的对话框。崩溃时间不固定跟操作有关纯玄学。原因一类是 plantthread 的信号连接在窗口关闭后没有安全停止线程还在 while 循环里跑窗口对象已被析构信号槽连接变成悬空另一类是线程里直接访问了 QLabel、进度条等 UI 对象QTread 与主线程同时读写控件状态数据竞争。解决在 mainwidget 的析构函数里先将isRunning标志置为 false然后调用plantthread-quit()和plantthread-wait()确保线程完全退出后再销毁对象。跨线程访问的整改方案是把所有 UI 操作挪回主线程plantthread 只发信号。你可以在源码里全局搜索plantthread对控件成员的直接引用凡是看到的都需要改造成信号槽。我一般会在退出前加一段测试代码让线程空转 500ms 再 check 是否有崩溃能抓出大多数线程资源释放顺序的问题。6. 改造成完整版加一个新植物「寒冰射手」的落地四步走拿到这套源码只是第一步答辩时最加分的是展示你真正理解了代码并能扩展它。我推荐你做一个改动量小但演示效果明显的功能新增一个寒冰射手植物它的豌豆命中僵尸后让僵尸减速 30%持续 2 秒。这个功能涉及植物定义、卡片、射击逻辑、僵尸状态四层改动但每层只需要几行代码。第一步打开 plants.h在植物类型枚举里加入IceShooter并在 PlantConfig 配置表里补一行数据enum PlantType { Sunflower, Peashooter, IceShooter }; struct PlantConfigItem { int cost, hp, damage, interval; }; PlantConfigItem PlantConfig[] { {50, 80, 0, 10000 }, // 向日葵 {100, 100, 20, 700 }, {150, 100, 20, 700 } // 寒冰射手 };第二步在 plantscard 的初始化列表里给寒冰射手加一张卡片这一步要参考现有豌豆卡片的位置把IceShooter和图片路径绑定起来。第三步在 plantthread 的发射循环里对IceShooter类型的植物复用豌豆发射逻辑但标记一个icetrue的信号参数。第四步在 shootbox 的碰撞代码里处理减速if (bullet-isIce) { zombies[j]-slowFactor 0.7; zombies[j]-slowTimer 2000; // 2秒后恢复正常 }僵尸的移动逻辑里用speed * slowFactor计算实际步长slowTimer 在每帧更新里递减。接着重新编译运行种下寒冰射手等豌豆命中僵尸后观察它的移动速度明显变慢。如果没生效优先检查 PlantConfig 的数值有没有填对、IceShooter 有没有真的被种到地图上。验证通过后你还可以顺藤摸瓜加一个血量更厚的「铁桶僵尸」或者一波三只的高密度刷怪逻辑原理都是往对应列表里插入新对象。那次我给一位朋友改完寒冰射手编译运行一次通过当场就觉得这套源码的地基打得真不错——从那以后我拿到任何课设源码都会先按「删 .pro.user → 重新构建 → 跑一把原版 → 再做最小改动」这个顺序来这个习惯让我避开了至少十几回编译期和运行期的无谓折腾希望也能帮到你。本文还有配套的精品资源点击获取