简介:一套以QT与C++实现的植物大战僵尸游戏源代码,源自C++程序设计期末课程设计,适合高校学生、初学者及对Qt游戏开发感兴趣者学习参考。资源共31个文件,包含13个cpp源文件、12个h头文件及qrc资源、ui界面、pro工程等,整体压缩包仅20KB,结构精简,便于快速浏览核心逻辑。代码覆盖Qt框架GUI搭建、QGraphics场景与事件处理、植物与僵尸类设计、碰撞检测、多线程更新以及文件存档等关键环节,完整展示塔防游戏从界面到机制的实现路径。目前已有3084人学习下载,对于准备课程设计或想要了解C++/Qt游戏工程架构的读者,是一份轻量而实用的参考样例。
1. 期末课程设计选“植物大战僵尸”:一个能同时拿高分和保底的项目
很多人把 C++ 课程设计做成学生信息管理系统、图书管理系统,最后答辩时自己都提不起劲,老师也听得直打瞌睡。而“基于 QT 的植物大战僵尸”这个题目,天然覆盖了 C++ 面向对象三大特性、QT 的信号槽机制、事件循环、定时器、碰撞检测、资源管理这些课程考核点。换句话说,它是一道“展示型”题目——不是能不能做完的问题,而是做完之后答辩时有没有东西可讲的问题。这个项目最打动我的地方在于:它把游戏逻辑的复杂度控制在了两周到三周能完成的范围里,却能撑起一份结构清晰、亮点明确的课程设计报告。
如果你正处在“C++ 语法都懂,但不知道能拿来做什么”的阶段,或者期末还剩三周、需要一个能落地又不至于翻车的题目,这篇文章就是按你这种处境写的。我会从系统架构、类设计、最小可运行版本、踩坑清单到答辩辅导,完整过一遍我的实现方案。整个方案不依赖外部素材包,所有图形都用 QPainter 画或找简单的 emoji 替代,资源和代码全部自包含,方便在任何机器上编译运行。
说实话,我自己当年做这个题目时,前两周只写完了场景和向日葵的代码。但恰恰是因为把核心框架搭对了,后面追加植物和僵尸几乎是“填表”一样的工作。框架的优先级永远高于功能数量。
2. 拆解游戏系统:场景、植物、僵尸的消息链路设计
2.1 先想清楚三个类之间的数据流,再动笔写代码
植物大战僵尸的玩法本质是一个“定时产出 vs 定时入侵”的对抗模型,落到代码层面就是三条消息链路:植物被种下后产生阳光、阳光被收集后兑换新植物、僵尸沿路径前进并攻击植物。用 QT 的信号槽来表达这三条链路,比用循环遍历要自然得多,因为 QObject 的父子关系和信号槽机制天然支持对象间的松耦合通信。
我的做法是:把整个游戏区域拆成 5 行 9 列的网格,每个网格单元是一个 QGraphicsRectItem 子类,负责绘制草地背景和接收鼠标事件;每种植物的实例是一个 QGraphicsItem 子类,持有一个 QTimer 作为“产出时钟”;僵尸也是一个 QGraphicsItem 子类,持有自己的行走定时器和攻击定时器。三者之间不直接互相调用方法,而是通过 scene 事件和信号槽传递状态。
举个例子,当玩家点击一张植物卡片时,卡片发送cardClicked(int plantType)信号给主窗口,主窗口将当前选中状态切换为“种植模式”;鼠标在场景中点击某个网格时,该网格发送cellClicked(int row, int col)信号,主窗口检查该网格是否为空、阳光是否足够,然后创建对应的植物实例并放到该网格坐标上。这个链路里每一步都只依赖信号和状态,新增一种植物只是新增一个类并注册到工厂映射里,其他代码完全不动,这是后期扩展能省力的关键。
enum class PlantType { Sunflower, Peashooter, Wallnut, CherryBomb }这类枚举定义放一个公共头文件里,所有类都包含它,不要各自定义。这是我第一版代码里踩过的一个坑:两个类各写了一套植物类型枚举,类型值对不上,导致种下去的向日葵释放出豌豆子弹,排查了很久才找到原因。统一枚举类型是这种多类协作项目的底线要求。
2.2 类的划分:暴露继承、多态和组合的课程设计亮点
课程设计的评分往往看重两点:一是代码里能不能看到“面向对象的设计痕迹”,二是能不能口头讲清楚“为什么这么设计”。植物大战僵尸这个题目恰好两样都能满足。我最终的类结构分三层:基类Plant和Zombie负责共有的属性(生命值、所在行列、朝向、状态)和行为接口(updateState()、handleHit()、onTimeout());派生类覆盖各自的独特行为,比如向日葵重写阳光产出逻辑、豌豆射手重写攻击逻辑、坚果墙只实现受击和死亡;第三层是工厂类和场景类,工厂类通过std::unordered_map<PlantType, std::function<Plant*()>>做类型到构造函数的映射,场景类负责将创建出来的对象加入QGraphicsScene并建立信号槽连接。
如果你担心这种设计会显得“教科书味”太重,答辩时我会建议用一个具体的例子来撑住:比如讲到Plant基类的hp_成员和takeDamage(int dmg)方法时,问一句“坚果墙和豌豆射手面对 5 点伤害有什么不同”——坚果墙重写takeDamage只减血没有额外状态,豌豆射手在血量低于 30% 时进入燃烧状态攻击力翻倍,这就是多态的具体体现。
僵尸类同理,但比植物多一个关键概念:沿格子移动。我让Zombie持有当前所在的列号col_和行号row_,每个移动定时器超时后调用setX()向左移动一个网格宽度(例如 80 像素)。为什么不直接用moveBy()?因为课程设计版本不追求平滑运动,按网格移动可以让碰撞检测退化成单纯的行列比较,大幅减少 QGraphicsItem 自带的碰撞检测开销,而且逻辑更好讲清楚。移动定时器的 interval 就是僵尸的行进速度,我在配置文件里统一设为 1200 毫秒每格,模拟从右向左穿越 9 列大约需要 10 秒,给玩家留出反应时间。
2.3 信号槽选型与集中式消息中枢:为什么不用轮询
新手容易犯的一个错误是:在每个植物和僵尸的advance(int phase)里遍历场景里的所有对象,判断是否要攻击、是否被攻击。这种轮询写法在小场景里跑得动,但每帧 O(n²) 的复杂度会让你在加了 20 个对象之后就感到明显的卡顿,而且代码里到处是qgraphicsitem_cast<Zombie*>(item),答辩时讲不清楚。
我的方案是引入一个消息中枢GameMediator类(继承 QObject,单例模式),植物和僵尸之间不直接通信,而是向中枢发送信号。比如豌豆射手发射的豌豆命中僵尸后,豌豆对象发送bulletHit(int row, int col, int damage)给中枢,中枢查询该行该列是否有存活僵尸,有则调用zombie->takeDamage(damage)。这个设计的收益是:游戏主逻辑全部集中在一个文件里,调试时只需要盯着GameMediator::onBulletHit这一个函数看日志,不用在几十个对象之间跳来跳去。
信号槽的线程注意点在这里也要提前说:QT 的信号槽默认在同一个线程内同步执行,emit一个信号会阻塞直到所有槽函数返回。如果你的碰撞检测槽函数里做了复杂的遍历或休眠操作,UI 会卡顿。我后来把所有游戏主循环的耗时操作都控制在 1ms 以内,凡是有可能超过这个量的逻辑(比如生成新一波僵尸)都拆成小步执行,每次只生成 1 到 2 个,间隔 200ms 再生成下一批。
下面是GameMediator的核心头文件代码骨架,展示了信号和槽的集中分布方式:
// GameMediator.h #pragma once #include <QObject> #include "GameModels.h" class GameMediator : public QObject { Q_OBJECT public: static GameMediator* instance(); // 单例访问入口 signals: void sunCollected(int amount); // 阳光数量变化 void zombieReachedHouse(int row); // 僵尸走到最左侧,游戏扣命 public slots: void onCellClicked(int row, int col); // 网格点击,尝试种植物 void onCardClicked(PlantType type); // 卡片点击,切换种植类型 void onBulletHit(int row, int col, int damage); // 子弹命中检测 void onPlantDied(Plant* plant); // 植物死亡后的清理 void onZombieDied(Zombie* zombie); // 僵尸死亡后的计分 private: GameMediator(QObject* parent = nullptr); // 私有构造,保证单例 static GameMediator* instance_; };参数说明:row和col用普通 int 而不是QPoint,是为了在多条信号链路上少构造一个临时对象,这类小优化对游戏帧率有实际影响;PlantType用枚举而不是字符串,可以从编译期杜绝类型名写错的问题。instance_静态指针在instance()首次被调用时初始化,不需要考虑多线程问题,因为游戏全在 GUI 线程内运行。
UI 侧尽量减少对场景对象的直接操作。所有按钮状态、阳光数值、倒计时都通过GameMediator的信号刷新,这样在后期加音效、加动画特效时,只需要再连接一个新槽函数,不用改动既有业务代码。这个模式在答辩时也能作为“松耦合设计”的例证讲给老师听。
3. 搭出最小可运行版本:从空场景到一颗子弹命中僵尸
3.1 场景与网格地图:用 QGraphicsScene 的四个参数把坐标一次对齐
第一步先把游戏场景和可视网格搭出来。这一步的目标不是好看,而是让鼠标点击位置和网格索引能够一一对应。网格尺寸我定为cellWidth = 80、cellHeight = 96,场景左上角起点offsetX = 220、offsetY = 80,这样左边预留出卡片区域和阳光数值显示区域,底部留出铲子工具条位置。这些数字写死在场景类的构造函数里,后续要调整布局只改这一处。
// GameScene.cpp 片段 void GameScene::initGrid() { for (int row = 0; row < 5; ++row) { for (int col = 0; col < 9; ++col) { QGraphicsRectItem* cell = new QGraphicsRectItem( offsetX + col * cellWidth, offsetY + row * cellHeight, cellWidth, cellHeight ); cell->setPen(QPen(QColor(0, 128, 0))); cell->setBrush(QBrush(QColor(34, 139, 34))); cell->setData(Qt::UserRole, row * 100 + col); // 把行列编码进 item data addItem(cell); } } }逻辑说明:每个格子创建后把自己的行列信息编码进setData,后续鼠标点击时从QGraphicsScene::itemAt()拿到的 item 直接解析data(Qt::UserRole).toInt()就能得到行列号,省去了用坐标反推的计算。这种“item 自带身份”的技巧在处理大量同类图元时非常实用,尤其在碰撞检测和事件分发阶段。
常见的替代做法是给每个QGraphicsRectItem设置不同的setObjectName,但对象名是字符串,转换效率低且容易写错;用整数编码更直接。场景宽度 1200、高度 600 即可满足 5×9 网格加两侧操作区的布局,视口缩放我锁死为 1:1,不允许用户缩放,避免坐标换算的额外复杂度。
同样要注意的是坐标基准:QGraphicsRectItem的构造参数是左上角坐标 + 宽度 + 高度,不是中心点坐标。如果你之前用过QGraphicsPixmapItem的以中心定位方式,混用时会发现植物偏移半个格子的距离,这是新手最容易踩的坑之一,先统一全部用左上角定位,等所有对象都正常后再考虑局部调整。
3.2 种植链路:从卡片点击到植物落地的完整实现
种植链路是整个项目里第一个完整闭环,花半天时间把它跑通,后面的开发会顺畅很多。核心思路:卡片被点击后只记录“当前选中的植物类型”,不立刻创建对象;等鼠标在场景里点击了某个非空位置后再创建。这样设计的好处是避免了 QT 中“鼠标穿透”和“事件误触发”带来的问题——如果你在卡片点击事件里直接创建植物,那植物会种植在卡片的位置,而不是场景网格上。
// GameScene.cpp 鼠标事件 void GameScene::mousePressEvent(QGraphicsSceneMouseEvent* event) { QPointF pos = event->scenePos(); // 只处理场景网格区域内的点击 if (pos.x() < offsetX || pos.x() > offsetX + 9 * cellWidth) return; if (pos.y() < offsetY || pos.y() > offsetY + 5 * cellHeight) return; QGraphicsItem* cellItem = itemAt(pos, QTransform()); if (!cellItem) return; int encode = cellItem->data(Qt::UserRole).toInt(); int row = encode / 100; int col = encode % 100; if (GameMediator::instance()->tryPlant(row, col)) { // 成功种植后要在场景中显示植物对象 Plant* p = PlantFactory::create(GameMediator::instance()->currentPlantType()); p->setPos(offsetX + col * cellWidth + 10, offsetY + row * cellHeight + 10); addItem(p); GameMediator::instance()->registerPlant(row, col, p); } QGraphicsScene::mousePressEvent(event); }参数说明:tryPlant(row, col)在GameMediator里负责检查网格是否为空、阳光是否足够;如果成功会扣除阳光、在网格占用表中写入该植物的指针。setPos里加的 10 像素是让植物在格子内居中并留出边距,看起来不那么拥挤。PlantFactory::create是根据当前选中类型动态创建具体植物实例的工厂方法,这也是把新增植物的工作量降到最低的关键设计——后面加一种新植物只需要在工厂里多注册一行映射代码。
种植链路完成后,务必手动测试两个场景:连续点击两个不同格子,确认第一个格子中的植物不会被覆盖;点击卡片但不点击场景,确认阳光不会被误扣。这两个 bug 是我在开发过程中实际遇到过且修复耗时最长的,根源都在于状态没有清理——卡片点击后没有重置当前选中类型,导致永远停在“种植模式”。我的解决方案是:点击卡片后同时发一个plantModeAborted信号,主窗口里用一个QTimer::singleShot重置状态,双重保险。
3.3 攻击与碰撞检测:让豌豆子弹去找僵尸,而不是等僵尸来撞
攻击逻辑的正确姿势是子弹主动查询目标,而不是僵尸主动检测子弹。因为子弹是短生命周期对象,它发射出去后每一帧自己移动并检查是否碰到目标,命中后立即销毁自己,生命周期干净利落。僵尸侧不做任何“被击中”的主动感知,只被动地接收takeDamage()调用,当血量归零时发出死亡信号。
// Bullet.cpp 核心逻辑 void Bullet::advance(int phase) { if (phase != 1) return; // 只在移动阶段更新 setX(x() + speed_); // speed_ 默认 6 像素/帧 QList<QGraphicsItem*> collisions = collidingItems(); for (QGraphicsItem* item : collisions) { Zombie* zombie = qgraphicsitem_cast<Zombie*>(item); if (zombie && zombie->alive()) { zombie->takeDamage(damage_); emit hitTarget(zombie); scene()->removeItem(this); deleteLater(); return; } } if (x() > scene()->sceneRect().right()) { scene()->removeItem(this); deleteLater(); } }逻辑说明:这里用了QGraphicsItem::collidingItems()来做碰撞检测,而不是手动比较坐标。QT 的碰撞检测基于 shape 默认的 boundingRect,对于矩形植物和子弹来说足够用。注意phase的判断——QT 的QGraphicsScene::advance()会调用每个 item 的advance(int phase)两次,第一次 phase 为 0(准备阶段),第二次为 1(移动阶段)。只在 phase 为 1 时处理移动和碰撞,可以避免一帧内重复移动两次导致子弹速度翻倍的问题。
参数理解:speed_ = 6像素/帧,按 60 帧/秒计算就是 360 像素/秒,穿越一个 80 像素宽度的格子只需要约 0.22 秒,玩家观感适中,僵尸在 5 行中任意一行被豌豆命中后都有时间反应。damage_默认设为 20,配合僵尸 100 点生命值,需要 5 颗子弹击杀一只普通僵尸,节奏合理。如果觉得攻击太快,可以调小speed_或调大僵尸血量,但建议先保持这个默认参数跑一局,再按手感微调。
这里还有一个对答辩很有用的点:deleteLater()不会立刻析构对象,而是在当前事件循环结束后才销毁,因此即使在槽函数中删除了一个还在发射信号的对象,也不会导致悬空指针访问。我在第一版代码里用的是delete this,结果经常在连续发射子弹的瞬间崩溃,改成deleteLater()后问题彻底消失。
4. 把植物和僵尸的生命周期管理好:状态机、定时器和内存回收
4.1 Plant 类的设计:状态切换、定时器节奏和死亡清理
植物是场景中数量最多、生命周期最复杂的对象。我给它定义了 4 种状态:Growing(刚种下,模型缩放动画)、Idle(静默等待,向日葵积攒阳光中,豌豆射手等待目标进入射程)、Attacking(攻击中,定时发射子弹)、Dying(血量耗尽,播放 500ms 的凋零动画后移除)。这个状态机的好处是:所有派生类只关心自己所在状态下的行为,而状态之间的转移条件和时机由基类的统一心跳方法控制。
// Plant.cpp 状态更新核心 void Plant::updateState() { if (hp_ <= 0) { state_ = PlantState::Dying; if (!dyingStarted_) { dyingStarted_ = true; QTimer::singleShot(500, this, &Plant::onDieAnimFinished); } return; } if (state_ == PlantState::Dying) return; // 向日葵型:时间到自动产阳光 if (type_ == PlantType::Sunflower) { if (timerElapsed_ >= produceInterval_ && state_ == PlantState::Idle) { emit produceSun(row_, col_); timerElapsed_ = 0; } } // 豌豆射手型:检查同行是否有僵尸 if (type_ == PlantType::Peashooter) { bool hasZombie = GameMediator::instance()->rowHasZombie(row_); if (hasZombie && !attackTimer_->isActive()) { attackTimer_->start(attackInterval_); state_ = PlantState::Attacking; } else if (!hasZombie && attackTimer_->isActive()) { attackTimer_->stop(); state_ = PlantState::Idle; } } timerElapsed_ += updateTickMs_; }参数说明:updateTickMs_是基类统一的心跳周期,我设为 100ms,也就是每秒调用 10 次updateState()。produceInterval_对向日葵而言是 7000ms,即 7 秒产一次阳光,这个数值是参考原版游戏手感后定的,比默认值稍快一点,避免测试时等待太久。attackInterval_是豌豆射手的发射间隔,默认 1500ms,配合第 3 章说的子弹速度,击杀一只普通僵尸约需 7.5 秒,比较平衡。
注意QTimer::singleShot(500, this, &Plant::onDieAnimFinished)里this的使用——只要这个 Plant 对象还没有被deleteLater,lambda/slot 就能安全触发;但如果父对象先销毁,QT 会自动断开这个定时器连接,避免回调悬空。这是 QT 对象树机制带来的一个重要安全网,答辩时可以主动提一句。
植物销毁的完整路径是:hp_ <= 0→ 状态切到Dying→ 500ms 动画 →scene()->removeItem(this)→deleteLater()。同时在GameMediator::unregisterPlant(row, col)中把该格子标记为空,允许重新种植。漏掉 unregister 会导致格子被永久占用,这是新手最容易漏掉的清理逻辑,我的建议是单独写一个Plant::onRemoved()回调,在里头统一做反注册,防止忘记。
4.2 Zombie 的生命周期和“攻防一体”的状态迁移
僵尸的状态相对简单,但有一个课程设计难点:僵尸既要前进,又要攻击,还要死亡,三者如何在一个类里不互相干扰。我定义了Moving(沿行前进)、Eating(攻击植物)、Dying(死亡动画)三种状态,迁移规则是:移动定时器超时后先检查当前格子和下一格子是否有植物,有则切到Eating并启动攻击定时器,没有则继续setX向左移动。
// Zombie.cpp 状态迁移 void Zombie::onMoveTick() { if (state_ == ZombieState::Dying) return; // 检测当前所在列是否已有植物 int currentCol = (x() - offsetX) / cellWidth; if (currentCol < 0) { emit reachedHouse(row_); return; } Plant* target = GameMediator::instance()->plantAt(row_, currentCol); if (target) { setState(ZombieState::Eating); if (!attackTimer_->isActive()) attackTimer_->start(attackSpeedMs_); return; } // 无植物阻挡,继续前进 setX(x() - moveStep_); // moveStep_ = 60 像素/次 setState(ZombieState::Moving); }注意里头的坐标转换:(x() - offsetX) / cellWidth把像素坐标还原成列号,这个换算在场景定位一改就全崩,所以在写GameScene构造函数时要把offsetX和cellWidth存成静态成员,Zombie才能访问。我第一版直接把偏移写死在僵尸里,后来想调整网格间距时,花了两个小时改所有类里的魔数,教训惨痛。现在所有与网格相关的常量都集中在一个GridConfig.h头文件里,改参数只动一处。
僵尸的移动步长moveStep_ = 60像素/次,配合移动定时器 1200ms,等同于之前说的每 1.2 秒走一格(80 像素)。这里有一个微妙的差异:60 像素和 80 像素的格宽不整除,所以僵尸在走到第 2 或第 3 个格子时会出现视觉上“没对齐”的现象。解决方式有两种:一是步长设成 80,定时器 1.5 秒,严格一格一格跳;二是步长 80 不变、间隔设 0 然后按帧移动。我采用的是第一种,牺牲平滑度换代码可读性,这也更符合课程设计的定位——答辩时你能清楚解释为什么选择不整除参数,本身就是亮点。
4.3 定时器风暴:避免每个对象各开一个 QTimer 导致的性能陷阱
场景里有 20 个植物、15 个僵尸时,如果每个对象都持有独立的QTimer,QT 的事件循环会同时维护 35 个定时器,虽然现代 CPU 完全跑得动,但在低配 Windows 虚拟机里还是会感觉到轻微的输入延迟。我的替代方案是:场景里只用一个全局游戏心跳QTimer(interval = 100ms),它的槽函数遍历所有存活的植物和僵尸,调用各自的updateState()和onMoveTick()周期性任务。
这样做的额外好处是:所有对象的逻辑更新频率完全一致,调试时只要在心跳槽函数入口打一个断点,就能观察到全场景的状态一致性;如果改为每个对象独立定时器,断点会随机散落在各个对象的回调里,排查“为什么阳光产生速度不一样”这类问题时无从下手。当然,独立的攻击定时器(比如豌豆射手发射子弹的 1500ms 定时器)和动画定时器(死亡动画)仍然保留在各自类里,因为它们确实需要独立节奏。全局心跳负责的是那些“每帧或每百毫秒都要检查一遍”的逻辑,比如向日葵是否该产阳光、僵尸当前行是否有植物。
那个QTimer的启动时机也值得注意:要在GameScene的构造函数里启动,并通过connect连到GameScene::onGameTick()。如果放在主窗口创建之前启动,场景还没构建完,游戏就会在空场景中跑逻辑。我建议的启动位置是场景所有网格和初始植物创建完成之后,因为第一帧就需要遍历网格,过早开始会出现没有植物而僵尸先入场的情况。
这套“单一心跳 + 对象自更新”的架构,是我后来给这个项目扩展“樱桃炸弹全屏爆炸”和“寒冰射手减速”特效时还能十分钟就完成的原因:新增特效只需要在对应植物的updateState()里加一个分支,并在主心跳里多传递一个状态字段。架构的宽容度直接决定了后期扩展时是享受写代码还是痛苦改代码。
5. 避坑指南:QT 植物大战僵尸开发中的 5 个血泪教训
5.1 现象:植物能种下去,但点击格子后立即崩溃
原因:我在mousePressEvent里直接addItem,但在创建植物实例时没有检查GameMediator的网格占用表,导致同一格上同时存在两个QGraphicsItem;崩溃发生在collidingItems()检测到同位置的两个Plant对象并同时调用takeDamage时,其中一个已经deleteLater,另一个还持有它的原始指针。
解决:把种植逻辑收敛到唯一入口GameMediator::tryPlant(),只有在占用表未记录时才允许创建;同时在Plant构造函数里断言gridOccupied_ == false,从机制上杜绝双重种植。后续所有植物和僵尸的交互都通过GameMediator转发,不再直接拿到对方的裸指针再手动调用,这彻底解决了同一类问题的复发。
5.2 现象:僵尸走到最左侧后,游戏没有触发失败逻辑
原因:我在Zombie::onMoveTick()里通过emit reachedHouse(row_)发信号,但主窗口的连接方式用了Qt::AutoConnection且主窗口在僵尸信号发射时还没完全初始化,导致信号丢失。这一类“信号先于接收者初始化”的问题在 QT 开发里非常典型。
解决:把reachedHouse连接改为主窗口构造完成后显式调用connect,并且信号携带的row_用于确定是哪一条战线失守。测试时我用了一个全局计数器统计信号触发次数,确认每次到达都能被记录。更稳妥的方案是在主窗口的构造函数里先创建所有对象、再建立连接,最后才开始游戏,保证“连接先于信号”。
5.3 现象:资源文件路径错误导致图像显示为空白
原因:QT 的资源加载机制里,QUrl("qrc:/images/plant.png")前面多了空格或路径中包含了中文字符,在新版 QT 编译后可能无法正确加载。我在第一版里为了图方便,直接用相对路径"images/sunflower.png"加载资源,但项目的构建目录和源码目录不同,运行时路径永远找不到。
解决:统一使用资源前缀:/images/plant.png,并且将所有图片通过.qrc文件管理,编译进二进制可执行文件。这样既避免了路径问题,也让整个项目可以整体拷到其他机器上运行而不需要附带资源目录。这一点在课程设计答辩时几乎是必问的题目,提前在文档里写明资源管理方案是大加分项。
5.4 现象:自动回收了植物,但格子依然显示为不可种植
原因:QGraphicsScene::removeItem(this)移除了 item,但我没有在GameMediator::unregisterPlant中同步清理网格状态。这个 bug 的隐蔽性在于:界面上看起来很干净,但程序逻辑里的占用表还记录着那个格子被占用,比如同一个格子上再点一次有反馈无效,重启程序又正常了。
解决:在Plant的析构函数里调用unregisterPlant,确保任何途径销毁植物都会同步清理占用表。同时补一个Scene::cellIsFree(int row, int col)的调试函数,在种植前打印所有被占用的单元格,方便随时检查状态一致性。这种“界面显示”和“逻辑状态”不同步的问题,是这个项目里耗时最多的坑,核心原因在于两个不同的模块(场景显示、逻辑数据)各自维护了一份状态。
5.5 现象:向日葵产阳光的间隔越来越快,像是作弊模式
原因:我在Plant里同时用了全局心跳的事件驱动和QTimer::singleShot两套计时机制,timerElapsed_被全局心跳累加,另一方面产阳光的定时器也在独立计时;当两个计时器恰好都触发时,阳光产出会加倍,看起来就像是加速了。
解决:统一所有周期性产出的逻辑,全部改成“全局心跳 + 内部计数变量”这一种模式,废弃内部自建的produceTimer_。内部计数由心跳累加,达到阈值才emit produceSun。这样计时逻辑只在一个地方维护,全局心跳的频率就是所有产出节奏的基准,再也不会出现双计时器竞争的情况。参数上我保留produceInterval_ = 7000,但心跳 100ms 意味着需要 70 次心跳才产一次阳光,计数精确无累积误差。
5.6 现象:新建的植物类型在工厂里注册后仍无法创建
原因:PlantFactory的映射表用的std::function<Plant*()>,但我没有在工厂注册表里包含新的头文件,编译器没有实例化新类的构造函数,于是工厂返回了空指针。这种编译期错误很隐蔽,常见于新增文件后忘记更新CMakeLists.txt或pro文件。
解决:每次新增类文件后,第一时间在工程文件里检查是否已加入编译源列表,然后再写工厂注册代码。推荐用 CMake 的glob自动收集源文件,避免手工维护列表带来的遗漏。答辩时被问到“如何扩展新植物”,可以从工厂模式的价值切入,同时说明 CMake 自动化带来的安全保障,这一套回答既有设计高度又有工程细节。
6. 答辩前必做的三件事:按参数调手感、读存档验证、讲解设计亮点
这个项目做到能跑通只是及格,想拿高分还得在细节上做打磨。第一件事是调参:把阳光初始值设为 150,避免前 30 秒玩家无事可做;向日葵的产出间隔 7000ms、豌豆射手的攻击间隔 1500ms、僵尸移动间隔 1200ms 这三组数值建议先跑一局,感受节奏后按每次 ±10% 微调。参数不是越小越好——太快的僵尸会让人来不及种植物,太慢的豌豆又让人觉得无聊。我把调参记录写成一张表格,每次改动标注改动前、改动后和试玩感受,方便答辩时展示你对手感这个“玄学”问题也有量化意识。
第二件事是在游戏开始时加载一个 JSON 格式的存档,里面记录当前波次、阳光数量和场上植物的行列位置。这样一个简单功能可以向老师展示你对“序列化”和“文件读写”的掌握,比口头说“我考虑了保存进度”有说服力得多。JSON 操作我用了 QT 的QJsonDocument和QJsonObject,不依赖第三方库,兼容性最好。存档文件放在可执行程序同目录下,用一个配置类统一管理读写。
第三件事是准备 3 分钟的设计亮点讲解。我的建议是:先说类结构——基类职责分明、派生类覆盖行为、工厂类负责创建,这是一个标准的多态应用;然后说信号槽——对象之间不直接调用,而是通过GameMediator转发,降低耦合;最后说计时机制——全局心跳统一驱动周期性逻辑,独立定时器只负责个别特殊节奏。这三点恰好覆盖了 C++ 课程设计考核的“封装、继承、多态、松耦合、事件驱动”五大要素,层次清晰。
我自己当年在答辩时,老师问了一个之前没准备的问题:“如果你要加一种每秒产出 2 点阳光并且能在 5 行任意位置种植的新植物,需要改动哪些文件?”因为我的工厂注册表和Plant类型枚举都在各自文件里互不干扰,我当时只改了两个文件:一个在PlantFactory注册新类,一个在PlantType枚举中添加新类型,然后重新编译,前后不超过 5 分钟。这种“当场演示扩展”的临场表现,比任何 PPT 截图都有说服力。
如果时间允许,还有一个进阶改进方向:添加一个简单的音效模块,用 QT 的QSoundEffect播放阳光收集和僵尸死亡的提示音。这个改动量不大,但会让游戏体验瞬间提升一个档次。音效资源可以录制或从开源站找免费素材,注意在项目文档里注明出处即可。回看这段开发过程,我最大的体会是:QT 课程设计的成败不是看功能多不多,而是看架构是否清晰、逻辑是否自洽、踩过的坑能否自己讲明白。希望这个项目方案能帮你少走一些弯路,祝答辩顺利。
本文还有配套的精品资源,点击获取