简介:本资源是一份基于Qt框架开发的C++期末课程设计项目——植物大战僵尸游戏的完整源码实现,面向高校C++程序设计初学者及Qt图形界面开发入门者,帮助其掌握Graphics View架构、面向对象设计思想与游戏逻辑建模方法。压缩包共111个文件,包含25个C++源文件(如zombie.cpp、plantmine.cpp)、25个头文件、28个GIF动画资源、21个PNG素材图及PDF开发文档、QRC资源文件、音效WAV与MP4演示视频等,整体39.43MB,结构清晰,模块划分明确。已有3829人学习下载,覆盖植物、僵尸、地图、商店等核心类体系,所有自定义类均继承QGraphicsItem,充分展现封装、继承与多态实践;附带完整开发文档与可直接编译运行的.pro工程,便于理解场景(Scene)、视图(View)与图形项(Item)协同机制,是深入理解Qt游戏开发流程的优质教学范例。
1. 用 Qt 写植物大战僵尸:为什么 C++ 课程设计选它,不是“炫技”,而是练透对象模型、事件循环和资源管理
很多同学拿到“C++程序设计期末课程设计”任务时,第一反应是做学生成绩管理系统或图书借阅模拟——安全、套路、容易交差。但当你看到“QT开发的植物大战僵尸游戏源代码”这个标题,别急着点开压缩包喊“太卷了”,先想清楚:这根本不是要你复刻 EA 的《植物大战僵尸》,而是一次对 C++ 核心机制的高强度压力测试。它强制你把课堂上抽象的“类继承”“信号槽”“内存生命周期”全扔进一个有动画、有碰撞、有时序、有资源加载的真实场景里跑一遍。我带过几届某高校的 C++ 实验课,发现凡是完整跑通这个项目的学生,后续在 Qt 工业项目里写自定义控件、处理多线程绘图、调试内存泄漏的直觉明显更强——因为他们在植物种下去那一刻,就亲手绑定了 QTimer 的 timeout 信号;在僵尸啃食植物时,第一次真正理解了 QObject 的父子关系如何决定 delete 的顺序;在切换阳光数值时,终于明白为什么 QLabel::setText() 看似简单,背后却牵扯到 QString 的隐式共享与堆内存分配。这不是游戏开发入门,而是 C++ 面向对象落地能力的一次闭环验证:从类设计(Plant/Zombie/Projectile)、到资源组织(图片/音效/配置)、再到运行时调度(游戏主循环 vs Qt 事件循环),每一步都踩在 C++ 与 Qt 交汇的刀锋上。适合谁?适合那些不满足于“编译通过”,想搞懂“为什么这么写才不翻车”的人。
2. 从零搭起游戏骨架:用 Qt Creator 新建项目、组织类结构、打通主循环与 Qt 事件流
2.1 创建最小可运行 Qt Widgets 项目并剥离冗余模块
Qt Creator 新建项目时,务必选择“Qt Widgets Application”,而非 Qt Quick 或 Console Application。原因很实在:植物大战僵尸的核心交互(鼠标点击种植物、拖拽阳光、实时碰撞检测)依赖 QWidget 的坐标系、paintEvent 和 mousePressEvent,而 Qt Quick 的 QML 渲染管线会绕过 C++ 对象生命周期管理,对初学者反而增加黑匣子。新建后,立即删除默认生成的 mainwindow.ui 和相关 .cpp/.h 文件——我们不需要 Designer 生成的 XML 布局,所有界面元素(草坪格子、阳光计数器、工具栏按钮)全部用纯 C++ 代码动态创建。这是关键一步:它迫使你直面 QWidget 的构造顺序、布局管理器(QVBoxLayout/QGridLayout)的嵌套逻辑,以及 setParent() 调用时机对内存释放的影响。删完后,main.cpp 只保留最简结构:
#include <QApplication> #include <QMainWindow> #include "GameScene.h" // 自定义游戏主场景类 int main(int argc, char *argv[]) { QApplication app(argc, argv); GameScene scene; // 继承自 QWidget,承载整个游戏画布 scene.show(); return app.exec(); // 启动 Qt 事件循环,这是游戏心跳的源头 }提示:
app.exec()不是普通函数调用,它是 Qt 事件循环的入口。所有鼠标、键盘、定时器信号都在此循环中被分发。游戏逻辑(如僵尸移动、植物攻击)必须挂载到这个循环上,不能另起 while(true) —— 否则 UI 会冻结。
2.2 设计核心类继承体系:Plant/Zombie/Projectile 的虚函数与多态调度
植物大战僵尸的实体行为差异极大:向日葵周期产阳光、豌豆射手发射子弹、樱桃炸弹瞬间爆炸、普通僵尸直线行走、铁桶僵尸抗多次伤害……用 if-else 判断类型再分支执行,代码会迅速腐烂。正确做法是建立清晰的基类-派生类体系。我们定义GameObject为顶层基类(纯虚),强制所有游戏实体实现update()(每帧逻辑)和render()(绘制):
// GameObject.h class GameObject : public QObject { Q_OBJECT public: explicit GameObject(QObject *parent = nullptr) : QObject(parent) {} virtual ~GameObject() = default; virtual void update() = 0; // 子类必须重写:位置更新、状态机推进 virtual void render(QPainter &painter) = 0; // 子类必须重写:用 QPainter 绘制自身 virtual QRectF boundingRect() const = 0; // 碰撞检测用的矩形区域 };然后派生Plant和Zombie,再由它们进一步派生具体类型(Sunflower、Peashooter、BasicZombie、ConeheadZombie)。关键点在于:所有派生类的 update() 必须是非阻塞的。例如豌豆射手的“发射逻辑”不能写sleep(1000),而应依赖 Qt 的 QTimer:
// Peashooter.h class Peashooter : public Plant { Q_OBJECT private: QTimer *m_shootTimer; // 每 1.5 秒触发一次发射 public: Peashooter(QObject *parent = nullptr) : Plant(parent) { m_shootTimer = new QTimer(this); // this 作为 parent,确保 timer 随植物销毁 connect(m_shootTimer, &QTimer::timeout, this, &Peashooter::shoot); m_shootTimer->start(1500); // 启动定时器,单位毫秒 } private slots: void shoot() { // 创建一颗新豌豆子弹,加入游戏场景管理器 auto *pea = new Pea(this); // Pea 也继承自 GameObject gameScene()->addGameObject(pea); // 场景类负责统一 update/render } };参数说明:
m_shootTimer->start(1500)中的1500是核心游戏平衡参数,直接对应原版豌豆射手射速。修改此处即可调整难度,无需改逻辑。
2.3 将游戏主循环嫁接到 Qt 事件循环:用 QTimer 驱动帧更新
传统游戏用while(running) { update(); render(); sleep(16); },但在 Qt 中必须放弃这种轮询。正确姿势是用单个全局 QTimer 作为“游戏时钟”,所有 GameObject 的 update() 都在此定时器的 slot 中被批量调用:
// GameScene.h class GameScene : public QWidget { Q_OBJECT private: QTimer *m_gameTimer; QList<GameObject*> m_gameObjects; // 所有活动游戏实体容器 public: GameScene(QWidget *parent = nullptr) : QWidget(parent) { setFixedSize(800, 600); // 固定窗口尺寸,简化坐标计算 m_gameTimer = new QTimer(this); connect(m_gameTimer, &QTimer::timeout, this, &GameScene::gameLoop); m_gameTimer->start(33); // ~30 FPS,33ms 一帧 } private slots: void gameLoop() { // 1. 更新所有实体状态 for (auto *obj : m_gameObjects) { obj->update(); } // 2. 检测碰撞(如僵尸碰到植物) checkCollisions(); // 3. 触发重绘 update(); // 调用 QWidget::update(),最终触发 paintEvent() } protected: void paintEvent(QPaintEvent *event) override { QPainter painter(this); // 4. 绘制所有实体 for (auto *obj : m_gameObjects) { obj->render(painter); } // 5. 绘制背景、UI 元素(阳光数值、暂停按钮等) drawUI(painter); } };注意:
m_gameTimer->start(33)的 33 是经验值。低于 20ms(50FPS)对 C++/Qt 无压力,但低于 10ms 可能导致 CPU 占用飙升;高于 50ms(20FPS)画面会明显卡顿。实际项目中建议封装为setTargetFPS(int fps)方法,内部计算1000/fps。
3. 图片资源与坐标系统:用 QPixmap 加载切片图、用网格系统定位植物/僵尸
3.1 将原始 PNG 图片转为 Qt 可用的 QPixmap 并管理生命周期
游戏中的植物、僵尸、子弹都是静态 PNG 图片,但直接new QPixmap("peashooter.png")会导致重复加载、内存泄漏。标准做法是建立ResourceManager单例,集中缓存已加载的图片:
// ResourceManager.h class ResourceManager : public QObject { Q_OBJECT public: static ResourceManager &instance() { static ResourceManager inst; return inst; } QPixmap getPixmap(const QString &key) { if (!m_cache.contains(key)) { QPixmap pixmap; if (pixmap.load(":/images/" + key)) { // 从 Qt 资源系统 qrc 文件加载 m_cache[key] = pixmap; } else { qWarning() << "Failed to load image:" << key; } } return m_cache[key]; } private: QHash<QString, QPixmap> m_cache; ResourceManager(QObject *parent = nullptr) : QObject(parent) {} }; // 使用示例:在 Peashooter::render() 中 void Peashooter::render(QPainter &painter) { QPixmap img = ResourceManager::instance().getPixmap("peashooter.png"); painter.drawPixmap(pos(), img); // pos() 返回当前植物在场景中的 QPointF 坐标 }关键细节:
":/images/peashooter.png"表示图片已添加到 Qt 资源文件(.qrc)中。必须提前在 Qt Creator 中右键项目 → “Add New…” → “Qt Resource File”,然后将图片拖入资源文件,并设置前缀为/images。否则pixmap.load()会失败。
3.2 构建 9x5 草坪网格系统:用行列索引映射像素坐标
原版植物大战僵尸的草坪是 9 行(僵尸路径)× 5 列(植物种植位),但图片坐标是像素值。手动计算每个格子的 x/y 太易错。解决方案是定义LawnGrid工具类,将逻辑坐标(row, col)映射到像素坐标(x, y):
// LawnGrid.h struct LawnGrid { static constexpr int ROWS = 9; static constexpr int COLS = 5; static constexpr int CELL_WIDTH = 120; // 每列宽度(像素) static constexpr int CELL_HEIGHT = 80; // 每行高度(像素) static constexpr int TOP_MARGIN = 80; // 草坪顶部留白(放工具栏) static constexpr int LEFT_MARGIN = 100; // 草坪左侧留白(放植物选择栏) static QPoint cellToPixel(int row, int col) { return QPoint( LEFT_MARGIN + col * CELL_WIDTH, TOP_MARGIN + row * CELL_HEIGHT ); } static QPointF pixelToCell(double x, double y) { int col = qMax(0, qMin(COLS - 1, static_cast<int>((x - LEFT_MARGIN) / CELL_WIDTH))); int row = qMax(0, qMin(ROWS - 1, static_cast<int>((y - TOP_MARGIN) / CELL_HEIGHT))); return QPointF(col, row); } };当玩家点击屏幕种植物时,mousePressEvent获取点击坐标,调用pixelToCell()得到(col, row),再检查该格子是否为空闲——这才是可维护的代码。所有植物的pos()坐标都应设为cellToPixel(row, col),保证严格对齐网格。
3.3 处理图片缩放与失真:用 QPixmap::scaled() 保持清晰度
原始素材图常为大尺寸(如 256x256),直接drawPixmap()会模糊。Qt 提供scaled()方法,但参数选择极关键:
QPixmap original = ResourceManager::instance().getPixmap("sunflower.png"); QPixmap scaled = original.scaled( 64, 64, // 目标宽高 Qt::KeepAspectRatio, // 保持长宽比 Qt::SmoothTransformation // 使用双三次插值,比 FastTransformation 清晰得多 ); painter.drawPixmap(pos(), scaled);血泪经验:若省略
Qt::SmoothTransformation,小尺寸缩放会出现严重锯齿,植物边缘毛刺感强烈。Qt::FastTransformation仅用于实时性要求极高且允许画质妥协的场景(如粒子特效),本项目必须用Smooth。
4. 信号槽与事件处理:用自定义信号解耦植物/僵尸交互,避免循环依赖
4.1 定义 ZombieDiedSignal 与 PlantDestroyedSignal:让僵尸死亡通知阳光系统
当僵尸被豌豆击中死亡时,需要触发两件事:1)播放爆炸音效;2)给玩家增加 50 阳光。如果在Zombie::update()中直接调用SunManager::addSun(50),会形成Zombie → SunManager → Zombie的隐式依赖链,且难以测试。正确方案是发射自定义信号:
// Zombie.h class Zombie : public GameObject { Q_OBJECT signals: void zombieDied(int sunValue); // 僵尸死亡时发出信号,携带阳光奖励值 public: void takeDamage(int damage) { m_health -= damage; if (m_health <= 0) { emit zombieDied(50); // 发射信号,不关心谁接收 deleteLater(); // 延迟删除,避免在 update() 中直接 delete 导致迭代器失效 } } }; // GameScene.cpp:在构造函数中连接信号 GameScene::GameScene(QWidget *parent) : QWidget(parent) { // ... 其他初始化 connect(this, &GameScene::zombieDied, this, &GameScene::onZombieDied); } void GameScene::onZombieDied(int sunValue) { m_sunManager->addSun(sunValue); // SunManager 是独立管理类 playSound("zombie_die.wav"); }注意:
deleteLater()是 Qt 对象安全销毁的黄金法则。在takeDamage()中直接delete this会导致update()循环中迭代m_gameObjects时访问已释放内存,程序崩溃。deleteLater()将销毁请求放入事件队列,等本轮事件循环结束再执行。
4.2 重写 mousePressEvent 实现植物拖拽:捕获鼠标按下/移动/释放全流程
种植物不是简单点击,而是“按住植物图标 → 拖到草坪 → 松开”。这需要重写GameScene::mousePressEvent、mouseMoveEvent和mouseReleaseEvent,并用状态机管理:
// GameScene.h private: bool m_isDraggingPlant = false; PlantType m_draggingPlantType; QPointF m_dragStartPos; // GameScene.cpp void GameScene::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { // 检查是否点击了工具栏上的植物图标 auto plantType = getPlantTypeAt(event->pos()); if (plantType != PlantType::None) { m_isDraggingPlant = true; m_draggingPlantType = plantType; m_dragStartPos = event->pos(); event->accept(); return; } } QWidget::mousePressEvent(event); } void GameScene::mouseMoveEvent(QMouseEvent *event) { if (m_isDraggingPlant) { // 更新拖拽中植物的预览位置(跟随鼠标) m_dragPreviewPos = event->pos(); update(); // 触发重绘,在 paintEvent 中绘制半透明预览图 event->accept(); return; } QWidget::mouseMoveEvent(event); } void GameScene::mouseReleaseEvent(QMouseEvent *event) { if (m_isDraggingPlant && event->button() == Qt::LeftButton) { // 松开时,检查鼠标位置是否在草坪网格内 QPointF cellPos = LawnGrid::pixelToCell(event->x(), event->y()); if (isCellValid(cellPos.x(), cellPos.y())) { // 在该格子创建新植物 auto *plant = createPlant(m_draggingPlantType, cellPos.x(), cellPos.y()); m_gameObjects.append(plant); } m_isDraggingPlant = false; event->accept(); return; } QWidget::mouseReleaseEvent(event); }关键点:
m_dragPreviewPos是临时变量,只在paintEvent()中用于绘制半透明预览图,不改变真实植物位置。真实植物创建发生在mouseReleaseEvent中,确保用户有明确的“放置确认”动作。
4.3 避免信号槽连接泄漏:用 Qt::UniqueConnection 与显式断开
信号槽连接若未管理,会导致同一对象被多次连接,一次事件触发多次响应(如僵尸死一次,阳光加了 5 次)。两个防护措施必须同时用:
- 连接时加
Qt::UniqueConnection标志:
connect(zombie, &Zombie::zombieDied, this, &GameScene::onZombieDied, Qt::UniqueConnection);- 在对象销毁前显式断开:
// GameScene::removeGameObject(GameObject *obj) void GameScene::removeGameObject(GameObject *obj) { disconnect(obj, nullptr, this, nullptr); // 断开 obj 发出的所有信号到 this 的连接 m_gameObjects.removeOne(obj); obj->deleteLater(); }5. 常见问题排查:5 个真实踩坑记录,现象、原因、解决全说透
5.1 现象:游戏运行几分钟后卡死,CPU 占用率 100%,调试器显示卡在QPainter::drawPixmap
原因:在paintEvent()中反复new QPixmap加载图片,而QPixmap构造函数内部会触发磁盘 I/O 或解码,阻塞主线程。更隐蔽的是,某些 PNG 图片含 ICC 颜色配置文件,Qt 解码时消耗巨大。
解决:
- 严格使用
ResourceManager单例缓存QPixmap,禁止在render()或paintEvent()中调用QPixmap::load()。 - 对所有原始 PNG 图片用 ImageMagick 批量剥离颜色配置文件:
mogrify -strip *.png。 - 在
ResourceManager::getPixmap()中增加加载耗时日志,超过 5ms 即告警。
5.2 现象:僵尸走到草坪边缘后消失,但Zombie对象未被销毁,内存持续增长
原因:Zombie::update()中判断“走出边界”的条件错误。例如写成if (x > width()) deleteLater();,但x是中心点坐标,而width()是窗口宽度,未减去僵尸图片宽度的一半,导致僵尸中心超出窗口才销毁,此时僵尸实例已脱离m_gameObjects管理,但仍在update()中被调用。
解决:
- 边界检测必须用
boundingRect():if (this->boundingRect().right() > width()) { deleteLater(); return; } - 在
Zombie::boundingRect()中返回精确包围盒:return QRectF(-m_width/2, -m_height/2, m_width, m_height);(以中心为原点) - 在
GameScene::gameLoop()开头加断言:Q_ASSERT(!m_gameObjects.contains(nullptr));,快速暴露空指针。
5.3 现象:点击植物工具栏图标后,拖拽预览图闪烁、位置跳变
原因:mouseMoveEvent()中直接调用update(),导致paintEvent()频繁触发,而paintEvent()内部又调用了QPainter::drawPixmap(可能触发图片缩放),形成性能雪崩。更糟的是,QPainter在非paintEvent()中调用会崩溃。
解决:
- 拖拽预览图不走
paintEvent(),改用QLabel作为浮动层:m_dragPreviewLabel = new QLabel(this); m_dragPreviewLabel->setWindowFlags(Qt::FramelessWindowHint | Qt::Tool); - 在
mouseMoveEvent()中只更新m_dragPreviewLabel->move(event->globalPos());,由系统管理重绘。 - 预览图用
QPixmap::scaled()预先生成,避免实时缩放。
5.4 现象:多个豌豆射手同时发射,豌豆子弹重叠粘连,碰撞检测失效
原因:Peashooter::shoot()中创建Pea对象时,未设置其初始位置为“豌豆射手前方”,而是默认(0,0)。所有豌豆都从同一坐标出发,后续update()中的位移计算无法分离。
解决:
Pea构造函数必须接收发射者位置和方向:Pea(const QPointF &startPos, const QPointF &direction)- 在
Peashooter::shoot()中:auto *pea = new Pea(mapToGlobal(pos()), QPointF(1, 0));(向右发射) Pea::update()中按方向向量移动:m_pos += m_direction * m_speed * deltaTime;(需实现帧时间 delta 计算)
5.5 现象:程序退出时崩溃,调试器显示QObject: Cannot create children for a parent that is in a different thread
原因:QTimer或QSound对象在非主线程创建,但 Qt 的 GUI 类(QWidget,QPixmap,QPainter)只能在主线程使用。常见于在Zombie::update()中直接new QSound("die.wav")。
解决:
- 所有
QSound、QMediaPlayer实例必须在GameScene构造函数中创建,并作为成员变量持有。 - 播放音效时只调用
m_soundPlayer->play(),不 new 不 delete。 - 若需后台加载资源,用
QThreadPool+QRunnable,但加载结果(如QPixmap)必须用QMetaObject::invokeMethod()投递回主线程处理。
6. 进阶技巧:用 QPropertyAnimation 实现平滑动画、用 QSettings 持久化游戏进度
6.1 用 QPropertyAnimation 替代手写 update() 实现植物生长动画
豌豆射手从种下到开始射击有 7.5 秒“冷却”,原版表现为植物逐渐长大。手写update()控制缩放比例易出错。QPropertyAnimation是 Qt 提供的声明式动画方案,更健壮:
// Peashooter.h private: QPropertyAnimation *m_growAnimation; // Peashooter.cpp 构造函数中 Peashooter::Peashooter(QObject *parent) : Plant(parent) { // 创建动画,控制 Plant 的 scale 属性(需在 Plant 类中声明 Q_PROPERTY) m_growAnimation = new QPropertyAnimation(this, "scale"); m_growAnimation->setDuration(7500); // 7.5 秒 m_growAnimation->setStartValue(0.1); // 初始缩放 10% m_growAnimation->setEndValue(1.0); // 最终缩放 100% m_growAnimation->setEasingCurve(QEasingCurve::OutInQuad); // 缓入缓出 connect(m_growAnimation, &QPropertyAnimation::finished, this, &Peashooter::onGrowFinished); m_growAnimation->start(); } // Plant.h 中必须添加 Q_PROPERTY(qreal scale READ scale WRITE setScale) private: qreal m_scale = 1.0; public: qreal scale() const { return m_scale; } void setScale(qreal s) { m_scale = s; update(); // 缩放变化需重绘 }注意:
QPropertyAnimation会自动在每帧调用setScale(),因此Plant::render()中必须用painter.scale(m_scale, m_scale)应用缩放,而非硬编码尺寸。
6.2 用 QSettings 保存/读取最高分与阳光收集数:跨启动持久化
游戏结束后,玩家希望看到“历史最高分”。QSettings是 Qt 跨平台配置存储方案,比手写 JSON 更轻量:
// GameScene.cpp void GameScene::onGameEnd() { QSettings settings; int currentScore = m_score; int bestScore = settings.value("game/best_score", 0).toInt(); if (currentScore > bestScore) { settings.setValue("game/best_score", currentScore); emit newBestScore(currentScore); } // 同时保存本次收集的阳光总数 settings.beginGroup("session"); settings.setValue("sun_collected", m_sunManager->totalCollected()); settings.endGroup(); } // 启动时读取 GameScene::GameScene(QWidget *parent) : QWidget(parent) { QSettings settings; m_bestScore = settings.value("game/best_score", 0).toInt(); // ... 初始化 }提示:
QSettings在 Windows 写入注册表HKEY_CURRENT_USER\Software\[OrganizationName]\[ApplicationName],macOS 写入~/Library/Preferences/,Linux 写入~/.config/,完全透明,无需关心路径。
6.3 一个血泪教训:永远在析构函数中显式 delete 所有 new 出的 QObject 子类
某次调试中,发现GameScene析构后,Zombie对象仍在update()中被调用,导致崩溃。根源是:Zombie的 parent 是GameScene,但GameScene::~GameScene()中未显式qDeleteAll(m_gameObjects),而 Qt 的 parent-child 机制只在 parent 析构时 delete child,前提是 child 的 parent 设置正确且未被中途setParent(nullptr)。
我的固定写法:
GameScene::~GameScene() { // 1. 停止所有定时器 m_gameTimer->stop(); // 2. 显式清理所有 GameObject qDeleteAll(m_gameObjects); m_gameObjects.clear(); // 3. 清理其他资源 delete m_sunManager; m_sunManager = nullptr; }这不是过度防御,而是 Qt 对象生命周期管理的铁律。
qDeleteAll()会调用每个指针的delete,并置空指针(若容器是QList<GameObject*>),避免野指针。希望帮到你。
本文还有配套的精品资源,点击获取