Qt/C++植物大战僵尸课设源码拆解:对象树、信号槽与游戏循环
2026/9/14 11:13:49 网站建设 项目流程

简介:这份基于Qt和C++框架编写的简易植物大战僵尸游戏源码,定位为计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的课程设计、毕业设计及课程大作业参考项目,也适合想要入门Qt图形界面编程和游戏开发的爱好者。压缩包内共包含37个文件,核心代码以17个C++源文件(.cpp)和16个头文件(.h)为主,分别负责游戏地图渲染、植物卡片选择、僵尸生成路径、阳光收集、子弹射击等核心逻辑,另有Qt工程文件(.pro)、资源文件(.qrc)和说明文档(.md),模块划分清晰,方便直接导入Qt Creator编译运行;整个包体积仅32KB,内容紧凑无冗余。项目代码经过运行测试,功能正常,可在现有框架基础上修改或增加新植物、新关卡、音效等玩法,也适合快速用于课程答辩或项目初期演示。已有535人学习下载,是一份轻量、完整且易上手的实践型源码。

1. 课程设计里那张 Qt/C++ 植物大战僵尸源码,到底在考什么

植物大战僵尸是 Qt 课程设计里出现频率最高的题目之一,一份基于 Qt 和 C++ 框架的简易版源码,解压后通常只有十几个 .cpp 和 .h 文件,却要覆盖对象树、信号与槽、QTimer 定时器、Qt 绘图、资源文件和碰撞检测这些核心机制。多数人卡住不是因为 C++ 语法,而是没想清楚「QTimer 驱动的游戏循环」和「while 死循环」的区别,以及 QGraphicsScene 场景图怎么和 QObject 对象树协作。下面按课设源码的常见结构拆解:先讲框架选型,再给构建命令,然后落到卡牌冷却、网格种植和子弹碰撞的实现,最后补三个能写进课程设计报告的收尾技巧。

2. 拆解 Qt 游戏框架:对象树、信号槽和 QTimer 怎么撑起一局游戏

一份 Qt/C++ 游戏源码拆开看,界面代码和 C++ 游戏代码的比例通常接近一比一。界面部分负责卡牌栏、阳光计数器和胜负弹窗,游戏逻辑则集中在三个骨架上:对象树如何管理植物和子弹的生命周期,信号与槽如何串联攻击与扣血,以及 QTimer 如何驱动每帧更新。把这三个骨架理解透,源码读起来就不费力。

2.1 对象树与父子关系:为什么网格上的植物不需要手动 delete

Qt 框架和其他 C++ GUI 框架最直观的差异是对象树。每个 QObject 构造时都能接收一个 parent 指针,父对象析构时自动销毁全部子对象。课设里典型的传承关系是:MainWindow 持有 GameWidget,GameWidget 内部挂一个 QGraphicsScene,场景里的每株 Plant 又以 MainWindow 为 parent 挂在对象树上。这样 new 出一株植物后不需要在任何地方 delete,窗口关闭时整棵树自上而下全部析构。这段逻辑是课程设计报告里「为什么选 Qt 框架」一节最值得写的技术理由,比「界面好做」这种话有分量得多。

// plant.cpp - 植物对象挂到对象树上的典型写法 Plant::Plant(const QString& type, const QPointF& pos, QGraphicsScene* scene, QObject* parent) : QObject(parent), m_type(type), m_pos(pos) { // 图元不是 QObject,不参与对象树,由场景统一管理 m_item = new QGraphicsPixmapItem( QPixmap(":/images/" + type + ".png")); m_item->setPos(pos); scene->addItem(m_item); }

这段代码的关键是分清两条生命周期线。Plant 继承 QObject,parent 传 MainWindow,析构时机由对象树控制;QGraphicsPixmapItem 属于 QGraphicsItem 体系,它由 QGraphicsScene 的图元列表管理,不归对象树管。答辩被追问「内存是怎么释放的」时,能讲出这两条线的区别,说明框架源码是真的看过,不是照着教程抄出来的。

2.1.1 对象树的边界:为什么删除植物用 deleteLater

对象树不是万能的。new 一个 QObject 时不传 parent,这块堆内存就只能手动 delete 或交给智能指针,否则就是泄漏;反过来,父对象一旦析构,所有子对象指针立即失效,继续访问就是悬垂指针。课设里植物被僵尸啃掉后的删除操作,正确写法是先让图元从场景移除,再调用 deleteLater(),而不是直接 delete。deleteLater() 会等事件循环回到空闲态再真正析构,避开了信号槽调用链上还可能存在的悬垂访问。这个细节在很多源码里是错的,代码评审时一眼就能看出来。

2.2 信号与槽:子弹命中僵尸的消息链路

信号与槽解决的是「谁变化了、谁要知道」的耦合问题。子弹打中僵尸,直白写法是子弹直接调用僵尸的 hit() 方法,两者绑死;Qt 的做法是子弹发 signal,僵尸在构造时 connect 到自己的槽函数,发射方和接收方互不感知。课设答辩通常就问两件事:子弹怎么通知僵尸扣血,植物怎么通知界面刷新阳光数字。答案都是信号与槽,而且用 lambda 槽可以少写两个成员函数。

// zombie.cpp - 构造函数里建立子弹到僵尸的通知链 Zombie::Zombie(QGraphicsScene* scene, QObject* parent) : QObject(parent), m_hp(100) { // 血量变化时检查是否死亡,发出 dead 信号由游戏逻辑处理 connect(this, &Zombie::hpChanged, this, [this](int hp) { if (hp <= 0) { emit dead(); this->deleteLater(); } }); }

这种写法最大的收益是扩展性。后加樱桃炸弹、火爆辣椒这类新植物时,只要复用同一套 hit 信号,Zombie 一行代码都不用改。传对象指针做信号参数时要注意生命周期:僵尸可能在信号发出后的某一帧已经销毁,迟到的子弹还带着它的地址找上门,所以槽函数里涉及指针参数的,第一件事先用 QPointer 判空,或者在删除前 disconnect 所有连接。课设运行崩溃的常见来源就是这里,僵尸被炸死后一帧内还有子弹在访问它。

2.3 用 QTimer 驱动游戏主循环,别用 while(1)

第一次写游戏循环的人容易把 while(true) 塞进构造函数,窗口直接卡死。原因不是循环本身慢,而是事件循环被阻塞后,重绘事件、鼠标事件全部排不上队。Qt 的标准做法是用 QTimer 把主循环切碎,每 16ms 做一小步更新,然后立刻把控制权还给事件循环,界面该重绘重绘,该响应响应。

// gamewidget.cpp - 以 16ms 为一步的游戏主循环 m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, &GameWidget::onTick); m_timer->start(16); void GameWidget::onTick() { updatePlants(); // 检查植物是否该发射子弹 updateBullets(); // 移动所有子弹并做碰撞检测 updateZombies(); // 移动僵尸、判断啃食 scene()->update(); // 请求整场景重绘 }

16ms 对应约 60 FPS,子弹和僵尸的移动速度按「每帧像素」调参,比按秒换算直观。注意 QTimer 的默认精度受操作系统调度影响,Windows 上通常到不了精确 16ms,但课设不追求帧同步没有影响。需要精确计时的场景,比如卡牌冷却的剩余秒数,用 QElapsedTimer 记录起点做差值,不要用 QTimer 的 interval 累加,系统休眠或窗口拖拽会造成定时器堆积,累加值会偏大。

3. 从源码到可运行:Qt 环境配置和首个构建命令

3.1 环境准备:Qt 版本与编译器的搭配

拿到源码的第一步是让它在自己机器上跑起来。课设源码最常对应 Qt 5.15.2,这个版本同时支持 qmake 和 CMake,安装包里自带 MinGW 编译器,新手不用额外配工具链。如果源码注释或 .pro 文件里写了 msvc2019_64,那就得装 Visual Studio,并选上「使用 C++ 的桌面开发」工作负载,同时保证 Qt 套件位数和 VS 工具链一致,32 位工程配 64 位编译器会在链接阶段报一堆 LNK2019。

3.1.1 安装组件选择和目录规划

安装时组件别全勾。课设项目只需要 Qt 5.15.2 下某一个编译器套件,MSVC 2019 64-bit 或 MinGW 8.1.0 选一个就够,如果源码用到了音效或视频,额外勾上 Qt Multimedia。Android、WebAssembly 这类平台组件用不上,装了只占磁盘。安装路径别带中文和空格,D:\Qt\Qt5.15.2 这种就正常,路径里一旦出现空格,后面的 windeployqt 部署脚本经常解析出错。

提示:解压源码后先读 .pro 或 CMakeLists.txt 顶部注释,确认源码是给哪个套件写的。MinGW 源码用 MSVC 编,或者反过来,都会在编译或链接阶段报一堆看不懂的错,先确认套件再动手安装,省掉两小时排错。

3.2 用 qmake 和 CMake 构建课设项目

带 .pro 文件的课设源码用 qmake 构建最省事。Qt Creator 里打开 .pro 选择套件点构建即可;命令行方式适合写进报告的构建说明,也方便在统一环境里复现:

# 建议在独立 build 目录构建,不污染源码 mkdir build && cd build D:/Qt/Qt5.15.2/5.15.2/msvc2019_64/bin/qmake.exe ..\PlantsVsZombies.pro nmake release

参数说明:qmake 后面的参数是工程文件相对路径;MSVC 套件用 nmake,MinGW 套件要把第二条命令换成 mingw32-make,两者不能互换。构建产物在 release\ 子目录,运行前还需要部署 Qt 运行时动态库,课设交付演示版必须做这一步:

D:/Qt/Qt5.15.2/5.15.2/msvc2019_64/bin/windeployqt.exe release\PlantsVsZombies.exe

windeployqt 会把程序依赖的 DLL 和 platforms 插件目录复制到 exe 同目录,这样把整个文件夹拷到没装 Qt 的机器上也能跑。运行 exe 时如果弹出 "could not find or load the Qt platform plugin windows" 的对话框,说明 platforms 目录没被正确复制,检查 exe 旁边是否存在 platforms\qwindows.dll,这是部署路径问题,不是代码问题。如果源码是 CMake 工程,构建流程换成标准四步,其中唯一必须手动指定的参数是 CMAKE_PREFIX_PATH,它指向 Qt 安装目录:

cmake -S . -B build -DCMAKE_PREFIX_PATH=D:/Qt/Qt5.15.2/5.15.2/msvc2019_64 cmake --build build --config Release

cmake 找不到 Qt5Widgets、Qt5Gui 这类包时,原因几乎都是 CMAKE_PREFIX_PATH 没指对,Qt Creator 里因为这个变量自动配好所以不报错,换到命令行就暴露了。

3.3 高频编译错误对照表

课设答辩现场最常见的编译错误就那几类,对照着排查比从头看日志快:

错误特征常见原因处理方式
LNK2019 无法解析的外部符号moc 文件没生成或没参与编译清理 build 目录后重新执行 qmake
未定义 vtable 链接错误类里有 Q_OBJECT 但 moc 没跑重新运行 qmake 生成 Makefile
C1083 找不到 QWidget 头文件Qt 模块没写进 .pro检查 QT += widgets gui 是否齐全
中文字符串乱码或报错源文件不是 UTF-8 编码Qt Creator 里另存为 UTF-8

注意:凡是错误里带 moc、vtable、Q_OBJECT 的,先别改业务代码,删掉 build 目录重新 qmake 一次,八成能解决。这是 Qt 元对象编译器生成时机的问题,不是代码逻辑的问题。

4. 复刻课设核心机制:卡牌冷却、网格种植与子弹碰撞

4.1 坐标映射:把鼠标点击换算成 5×9 网格

游戏场地在逻辑上是 5 行 9 列的网格,鼠标点击得到的是像素坐标,种植物前必须先把像素换算成行列号,再确定格子左上角位置。换算就是一个整除,但要写对除数:

// gamewidget.cpp - 像素坐标与网格下标互转 const int kRows = 5; const int kCols = 9; const int kCellW = 80; // 格子宽 const int kCellH = 100; // 格子高 bool GameWidget::pixelToGrid(const QPointF& pos, int* row, int* col) { *row = static_cast<int>(pos.y()) / kCellH; *col = static_cast<int>(pos.x()) / kCellW; if (*row < 0 || *row >= kRows) return false; if (*col < 0 || *col >= kCols) return false; return true; }

格子尺寸建议定义成常量,从场景尺寸统一计算,不要写死在多个类里。占格判断用一个 bool 二维数组 m_occupied[5][9] 就够,种下置 true,植物消失置 false。数组方案比每帧遍历场景图元做类型判断更快,代码也更好懂。等需求升级到支持「植物被南瓜头包裹」这类占位效果时,再把 bool 数组换成 Plant* 数组,一个改动同时解决「格子是否被占」和「占格的是谁」两个问题。

4.1.1 视图坐标和场景坐标:少一次转换就会整体偏移

用 QGraphicsView 的项目有一个高频坑:mousePressEvent 拿到的 position 是视图坐标,而植物出生位置期望的是场景坐标。两者在视图没有滚动缩放时数值相同,一旦滚动条出现或调了 scale,直接拿视图坐标算网格就会整体偏移,表现是「点击没反应」或「种到隔壁格」。正确顺序是先 mapToScene 再算行列:

QPointF scenePos = view->mapToScene(event->pos()); int row, col; if (pixelToGrid(scenePos, &row, &col)) { gridClicked(row, col); }

把 mapToScene 这一步写在事件处理的最前面,后续所有逻辑都基于场景坐标,样板代码集中在一个地方,比每个槽函数里各转一次好维护。

4.2 种植逻辑:阳光数量判断和卡牌冷却的落地写法

点击格子后要过两道闸:阳光够不够,卡牌冷却好没好。阳光是全局数值,变化通过信号广播;每张卡牌各带一个剩余冷却帧数,在 onTick 里统一递减。别为每张卡 new 一个 QTimer,对象数量膨胀不说,暂停游戏的逻辑还得逐一定时器处理,一个数组全搞定:

// cardwidget.cpp - 卡牌冷却的帧计数实现 struct CardState { int cooldownTotal; // 总冷却(帧) int cooldownLeft; // 剩余冷却(帧) bool sunEnough; // 阳光是否足够 }; void CardWidget::onTick() { for (auto& card : m_cards) { if (card.cooldownLeft > 0) card.cooldownLeft--; card.sunEnough = (m_sun >= card.sunCost) && (card.cooldownLeft == 0); update(card.rect); // 触发重绘,让置灰状态及时刷新 } }

帧计数冷却的好处是行为一致:游戏暂停时所有冷却同步暂停,不需要额外处理。阳光扣减也走信号链,emit sunChanged(m_sun - cost) 后,界面阳光标签和所有卡牌的灰态统一刷新,比手动 setText 可靠。每张卡牌的可见状态其实有三种:冷却中、阳光不足、可种植,三者的表现都不同,这个细节多数课设只做了后两种,主动做全了在答辩时是加分项。

4.3 碰撞检测:用场景坐标遍历代替自维护数组

子弹是否命中僵尸,最省事的写法是把子弹和僵尸都做成 QGraphicsItem,每帧查场景里与子弹包围盒相交的图元,而不是维护两份数组再嵌套循环。Qt 的场景图本身就承担空间查询的职责:

// bullet.cpp - 子弹每帧的位置更新与碰撞检测 void Bullet::advance() { QPointF nextPos = pos() + QPointF(m_speedX, 0); setPos(nextPos); // 查询新位置附近的图元,相交模式取包围盒判断 QList<QGraphicsItem*> items = scene()->items( QRectF(nextPos, QSizeF(20, 40)), Qt::IntersectsItemBoundingRect); for (QGraphicsItem* item : items) { if (item->type() == ZombieItem::Type) { emit hit(item); scene()->removeItem(this); // 先移出场景 deleteLater(); // 再安全销毁 break; } } }

几个要点:第一,scene()->items(rect, mode) 一次调用返回所有相交图元的列表,比逐个对比简单;第二,类型判断用 type() 返回的自定义枚举值,比 dynamic_cast 更贴合 QGraphicsItem 的扩展约定;第三,销毁子弹的顺序必须是先 removeItem 再从场景删除,顺序反了场景里留着悬垂指针,下一帧遍历直接崩溃。课设场景几十个图元,包围盒碰撞的精度完全够用,不用上像素级检测。

5. 让课设源码更像作品:QSS、QPainter 和 QSettings 三个收尾技巧

5.1 用 QSS 给卡牌加半透明冷却进度条

课程设计里卡牌冷却最常见的做法是置灰按钮,功能没问题但视觉效果一般。用一个 QProgressBar 叠在卡牌上,值从 100 递减到 0,背景色用半透明黑色,就能做出冷却遮罩逐渐消退的效果,整个过程不需要任何贴图:

QProgressBar#cooldownMask { background: rgba(0, 0, 0, 150); border: none; border-radius: 4px; text-visible: false; /* 不显示百分比数字 */ }

QSS 在 Qt 里实际是给 QStyle 的配置,运行时修改字符串再重新设置样式表能即时生效,所以冷却进度条的数值更新不需要重建控件,直接 setValue 驱动重绘即可。text-visible 是 QProgressBar 专有的样式表子属性,写错位置会被直接忽略,如果发现遮罩上冒出了数字,检查它是否写在了边框属性同一组里。

5.2 用 QPainter 画阳光动画,省掉整张贴图

简易版源码里阳光、子弹这类小物件如果都用图片,资源文件会越堆越多。继承 QGraphicsItem 重写 paint(),用 QPainter 画圆和线段就够,答辩演示时还能现场改动颜色参数说明绘制原理:

// sunlight.cpp - 用 QPainter 绘制阳光图元 void SunlightItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) { qreal glow = 90.0 + 60.0 * qSin(m_frame * 0.3); painter->setBrush(QColor(255, 220, 60, glow)); painter->setPen(Qt::NoPen); painter->drawEllipse(boundingRect()); // 光线绘制省略,核心变化是透明度随帧号波动 }

透明度随帧号做正弦波动,视觉上就有「呼吸」效果,代价只是每帧多画一个椭圆。注意 paint() 里不能改场景状态、不能 new QObject,它只负责画,要更新图元的属性值在外部 onTick 里改完再调用 update() 请求重绘,这个分工是 Qt 绘图模型的要求。

5.3 用 QSettings 存最高分和关卡进度,答辩时能现场验证

QSettings 是对系统配置文件的一层封装,写入一行读取一行,不需要自己解析文件格式。存最高分和当前关卡,一个函数就能收尾,报告里却可以单独写一小节:

// 存档与读档的完整写法 QSettings settings("MySchool", "PlantsVsZombies"); settings.setValue("game/highScore", m_highScore); settings.setValue("game/level", m_level); int savedScore = settings.value("game/highScore", 0).toInt();

构造函数里第一个参数是组织名、第二个是应用名,两者合成配置键的命名空间。验证方式很直接:运行一次让分数写进去,然后删除配置文件再启动,能回到默认值说明读档分支正确;或者打开注册表编辑器,在 HKEY_CURRENT_USER\Software\MySchool 下能看到完整键值;Linux 上则落在 ~/.config/MySchool/PlantsVsZombies.conf。答辩老师现场常问「数据存在哪」,直接打开注册表指到 HKEY_CURRENT_USER\Software\MySchool\PlantsVsZombies 这一条,比口头解释更有说服力,也证明存档逻辑不是写死的伪实现。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询