简介:这是一份基于Qt Graphics View框架开发的植物大战僵尸游戏完整课程设计源码包,面向C++程序设计课程的期末项目,适合需要完成同类游戏开发或学习Qt图形编程的高校学生。项目采用面向对象设计,通过封装、继承与多态实现植物、僵尸、商店等核心类体系,并配有开发文档与演示视频,便于理解整体架构与运行效果。资源包共111个文件,涵盖25个cpp源文件、25个头文件以及21个静态PNG素材,另有pro工程文件、PDF开发说明和演示视频等,压缩包约39.43MB,目录结构清晰,方便直接打开工程进行二次开发与学习。目前已有3825人学习下载。从中可获取一份结构完整、可直接运行的Qt游戏项目,学习Graphics View框架的使用、自定义图形项的交互设计,以及面向对象思想在游戏开发中的落地方式,也可作为课程设计答辩时的功能演示与代码讲解基础。
1. 从“期末课程设计”到可运行的Qt植物大战僵尸,差在哪
拿到这个标题,第一反应可能是把它解压、打开.pro、点运行。实际动手你才会发现,一个Qt植物大战僵尸课程设计能不能顺利交差,取决于三件事:类结构是否清晰、资源图片放在哪里、以及目标机器上有没有对应的Qt运行库。不要相信一个“源代码.zip”解压后就能双击运行;按Qt 5.15.2这个稳定基座来审视,这个工程应该可以拆成场景、实体、调度三个层次,而不是把所有东西塞进MainWindow里。接下来我从一个常见课设项目的角度,把植物的种植、僵尸生成、子弹碰撞、编译发布和答辩展示串一遍,让新手能跟着重构,也让有经验的读者看到边界和值得改进的地方。
2. 植物大战僵尸的面向对象设计:用QGraphicsItem承载多态
2.1 QGraphicsView还是QWidget:先按工期做决定
植物大战僵尸的渲染层有两条路。一条是重写QWidget的paintEvent,把所有植物、僵尸画在一张画布上,再用一个2D数组记录格子数据;另一条是使用QGraphicsScene搭配QGraphicsView,把每个角色封装成独立的item。如果是两个月以上的大作业,手绘paintEvent能让你拿高分,因为你能讲清楚整个绘制流水线;如果只剩两三周,我会建议直接用QGraphicsView。原因很简单:场景坐标转换、item之间的相交测试、鼠标点击命中测试都是现成的,你把精力放在对象关系上更划算。
两种方案的取舍可以列成一张对比表:
| 方案 | 代码量 | 碰撞检测 | 动画扩展 | 课程设计答辩亮点 |
|---|---|---|---|---|
| QWidget手动绘制 | 多 | 自己写矩形相交 | 需要管理dirty区域 | 展示底层渲染能力 |
| QGraphicsScene/View | 少 | Qt内置intersects | 每item独立更新 | 展示面向对象与架构能力 |
我见过不少课设源码,表面上用了QGraphicsScene,但游戏逻辑还是写在view的鼠标事件中,植物和僵尸的类只有数据成员,没有方法。这在答辩时是减分项。所以真正要做的是把逻辑下沉到实体类中,让view只负责接收用户输入和驱动场景。
2.2 实体基类:同时继承QObject和QGraphicsItem
一个成熟的框架会定义抽象基类GameEntity,它继承QObject和QGraphicsItem。注意QGraphicsItem在前,QObject在后,因为Qt的元对象系统要求QObject在继承列表中顺序靠后,否则编译会出现undefined reference to vtable。这个基类中,paint和boundingRect是纯虚函数,这保证每个子类必须定义自己的外观和碰撞范围。
// gameentity.h #include <QObject> #include <QGraphicsItem> class GameEntity : public QGraphicsItem, public QObject { Q_OBJECT public: explicit GameEntity(const QPointF &initialPos) : QGraphicsItem(), QObject(), currentTick(0) { setPos(initialPos); } QRectF boundingRect() const override = 0; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override = 0; virtual void updateByTick(int tick) { currentTick = tick; update(); // 触发场景重绘 } protected: int currentTick; int hp; int row; };代码说明:updateByTick是每帧的业务逻辑入口,QGraphicsItem::update()会通知scene在下一轮重绘这个item,不需要手动调用repaint。hp和row放在基类,是为了后续碰撞检测和伤害计算统一。使用时你还要在子类构造函数中调用setFlag(QGraphicsItem::ItemIsSelectable)或类似标志,如果不用鼠标选取,这步可以省略。
2.3 场景内的对象管理与网格索引
场景中会有植物、僵尸、子弹三种实体,它们有各自的行为和生命周期。比较稳妥的管理方式是在GameScene里维护三类容器:QList<Plant*> plants、QList<Zombie*> zombies、QList<Bullet*> bullets,同时用一个QHash<int, Plant*> gridPlantMap记录格子与植物的映射。之所以不让QGraphicsScene的items()直接承担查找,是因为items()返回QGraphicsItem的通用列表,每次都要qgraphicsitem_cast,代码既啰嗦又慢。
const int GRID_ROWS = 5; const int GRID_COLS = 9; QHash<int, Plant*> m_gridPlantMap; int cellIndex(int row, int col) { return row * GRID_COLS + col; }当放置植物时先检查该index是否存在plant,存在就拒绝放置。这样的好处是,判断“某格能不能种”的时间复杂度为O(1)。在游戏中后期,场景同时存在上百个item,用QGraphicsScene::itemAt逐个遍历是不可靠的,尤其是点选和网格种植判定这两种逻辑不该混在一起。
2.4 动画帧如何驱动:按tick而不是按time
Qt动画框架QPropertyAnimation可以处理按钮和简单的数值动画,但僵尸行走、植物摇摆这种循环帧动画,用QPropertyAnimation要写很多setKeyValueAt,维护成本高。我更喜欢在updateByTick里用计数器决定帧序号。假设僵尸有walk1.png、walk2.png、walk3.png、walk4.png四帧,每150ms换一帧,而主timer是20ms,那么帧间隔换算成7.5个tick,实际取8,意味着160ms一帧。
void WalkingZombie::updateByTick(int tick) { GameEntity::updateByTick(tick); int frame = (tick / 8) % 4; setPixmap(m_frames[frame]); // 如果用QGraphicsPixmapItem方式 }参数说明:m_frames在构造函数中从QPixmap缓存,不要每次paint都从磁盘加载。tick是全局递增计数器,不是每帧差值,所以帧切换与帧率无关。这样做还有一个优点,当你暂停游戏时只要停止timer,所有动画都会同步冻结。你用QTime::elapsed()则还得另外处理暂停逻辑。
3. 核心玩法落地的Qt实现:太阳、种植、生成与碰撞
3.1 用QTimer做固定步长主循环
游戏中最稳定的结构是一个20毫秒的主定时器,它在scene层触发onTick,然后由onTick通知所有实体。不要在每个植物类里单独起QTimer,那样当你有20个向日葵时就有20个timer,窗口切换、暂停时还要逐个处理,很容易出错。
GameScene::GameScene(QObject *parent) : QGraphicsScene(parent) { m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, &GameScene::onTick); m_timer->start(20); } void GameScene::onTick() { ++m_elapsedTick; for (Plant *p : m_plants) p->updateByTick(m_elapsedTick); for (Zombie *z : m_zombies) z->updateByTick(m_elapsedTick); for (Bullet *b : m_bullets) b->updateByTick(m_elapsedTick); tryGenerateSun(); handleBulletCollisions(); removeDeadEntities(); checkGameOver(); }这里的设计重点是:updateByTick只负责推进自身状态,碰撞和生成由scene统一管理。这样实体之间不互相持有指针,降低耦合。参数说明:20毫秒意味着每秒50次tick,向日葵生产阳光间隔如果设为600tick,刚好12秒,比较接近原版节奏。如果你希望帧率更高,可以改成10ms,但QTimer在Windows上的精度大约15ms,设10ms反而可能浪费CPU。
3.2 种植植物的坐标换算与检查
用户点击scene坐标后,先转成行列。我以每格宽80、高100为例。view要开启setMouseTracking(false)也行,只要在mousePressEvent处理点击即可。
void GameScene::mousePressEvent(QGraphicsSceneMouseEvent *event) { QPointF pos = event->scenePos(); int col = static_cast<int>(pos.x()) / CELL_WIDTH; int row = static_cast<int>(pos.y()) / CELL_HEIGHT; if (row >= 0 && row < GRID_ROWS && col >= 0 && col < GRID_COLS && !m_gridPlantMap.contains(row * GRID_COLS + col)) { if (m_plantCost <= m_sunPoints) { m_sunPoints -= m_plantCost; Plant *plant = createPlantAt(row, col); m_gridPlantMap.insert(row * GRID_COLS + col, plant); addItem(plant); } } }这段代码已经附带阳光扣除检查。要注意:createPlantAt返回的plant要和网格索引一起保存,否则后续在植物被吃掉后你无法从grid中移除。移除时应该在removeDeadEntities里做“查格子id->删条目”的联动,而不是直接delete item。
3.3 子弹与僵尸的碰撞检测:先同行过滤,再做矩形相交
原版植物大战僵尸的碰撞其实发生在同一行子弹和僵尸之间。如果对所有子弹和所有僵尸做双重循环,时间复杂度是O(B*Z)。简化后的代码先做行号过滤,仍然是最可读的方案:
void GameScene::handleBulletCollisions() { for (Bullet *b : m_bullets) { QRectF bulletRect = b->boundingRect().translated(b->pos()); for (Zombie *z : m_zombies) { if (z->row() != b->row()) continue; QRectF zombieRect = z->boundingRect().translated(z->pos()); if (bulletRect.intersects(zombieRect)) { z->setHp(z->hp() - b->damage()); b->setRemovedFlag(); break; } } } }参数说明:boundingRect返回的是局部坐标系矩形,必须translated(z->pos())变成场景坐标;如果直接用全局位置设置Entity pos,那么z->pos()是场景坐标。这里有个细节:僵尸的boundingRect建议只取图片实际占用的窄条,不要覆盖到旁边草地,否则玩家会看到子弹悬空也能命中。你可以用调试模式绘制边界来判断。
3.4 僵尸生成的调度表与难度曲线
生成僵尸我倾向使用事件表而不是纯随机。原因之一是期末考试演示时,你希望前30秒稳定出现若干波僵尸,让玩家有时间种植物;如果靠rand,很可能开局一下刷出五只,直接把演示搞砸。事件表可以这样设计:
| 时间(秒) | 僵尸类型 | 数量 |
|---|---|---|
| 10 | 普通僵尸 | 1 |
| 25 | 普通僵尸 | 2 |
| 40 | 锥桶僵尸 | 1 |
| 60 | 路障僵尸 | 1 |
在程序里用queue<pair<double, int>> m_waveQueue,键是游戏秒,值是类型。onTick中:
double currentTime = m_elapsedTick * 0.02; while (!m_waveQueue.empty() && currentTime >= m_waveQueue.front().first) { spawnZombie(m_waveQueue.front().second); m_waveQueue.pop(); }这样既保证可预测性,也保留了少量随机:僵尸出现的y坐标和具体行号可以随机,只要不落在已有植物的格子上即可。难度曲线可以很容易地通过修改这张表调节,不用改逻辑代码。
4. 把源代码编译成可执行文件:Qt版本、编译器与常见报错
4.1 从.pro文件开始,而不是直接双击
拿到.zip解压后的第一件事是看.pro和main.cpp。如果你的机器上同时装了学校要求的Qt 5.14和另一个Qt 5.15,那么打开项目前最好确认qmake版本。在终端执行qmake -v,输出里会带上5.15.2这样的版本号。如果命令行找不到qmake,就得打开Qt安装目录下的“Qt 5.15.2 (MinGW 8.1.0)”命令行环境。
一份最简.pro是这样:
QT += core gui widgets CONFIG += c++11 TARGET = PlantsVsZombies TEMPLATE = app SOURCES += main.cpp \ gameScene.cpp \ plant.cpp \ zombie.cpp \ bullet.cpp HEADERS += gameScene.h \ plant.h \ zombie.h \ bullet.h RESOURCES += game.qrc代码说明:CONFIG += c++11不是必须的,因为Qt 5.15默认支持C++17,但如果你用了constexpr,建议显式写c++17而不是c++11。QT += widgets必须放在四行内,QGraphicsScene和QGraphicsView都来自Qt Widgets模块。
4.2 使用qmake和make构建
在Windows上,如果你使用MinGW编译器,命令行如下:
mkdir build cd build qmake ..\PlantsVsZombies.pro -spec win32-g++ mingw32-make -j4如果用MSVC,则是:
nmake这里的关键是:不要在Windows上用make命令,因为MinGW的make叫做mingw32-make,MSVC环境用nmake。你可以通过qmake -spec参数观察具体差异。许多同学看到“Can't find file for -lgl”就直接跑去装OpenGL,其实在MinGW下只要添加CONFIG += console或LIBS += -lopengl32。
4.3 常见编译报错对症表
| 错误信息 | 真正的原因 | 解决动作 |
|---|---|---|
| Qt serialport: unknown module | 安装Qt时没勾选SerialPort | 重装勾选或删除.pro中该模块 |
| incompatible Qt library | PATH中存在多个Qt版本 | 用绝对路径调用qmake和编译器 |
| QGraphicsScene not found | .pro未加widgets模块 | QT += widgets |
| fatal error: QSoundEffect: No such file | Qt Multimedia模块未安装 | QT += multimedia |
| cannot open output file .exe | 程序还在运行 | 杀掉任务管理器中的旧进程 |
注意,如果你只是做植物大战僵尸,完全不需要serialport、multimedia等等。凡是.pro里有的模块,都必须是当前Qt环境实际安装过的。我见过最典型的错误是网上下载的源代码包含了串口模块,但在常规Qt SDK中默认不安装,于是整个项目无法编译。此时直接注释掉那行,通常不会有副作用。
4.4 发布到没有Qt环境的电脑:windeployqt
答辩演示不一定在开发机,所以发布打包非常关键。构建出release版本后:
cd release windeployqt PlantsVsZombies.exe --release工具会根据exe的依赖自动复制。不过它不会复制qrc里png这些图片,因为qrc编译进了二进制。真正需要额外关心的只有两种文件:Qt平台插件和mingw32.dll。如果你用的是MinGW编译器,还要把libgcc_s_dw2-1.dll、libstdc++-6.dll、libwinpthread-1.dll一起拷贝,否则目标机器提示缺少dll。一个推荐的做法是:在空目录中执行windeployqt,然后把需要的dll和qwindows.dll挪到exe旁边。不要直接COPY整个Qt安装目录,那会有几百兆垃圾文件。
5. 调试、参数调优与运行期性能:让植物大战僵尸有“手感”
5.1 用qDebug和场景统计定位逻辑错误
Qt程序调试的第一工具是qDebug,它可以输出到应用程序输出面板。在onTick开头这样写:
void GameScene::onTick() { if (m_debugMode) { qDebug() << "tick" << m_elapsedTick << "plants" << m_plants.size() << "zombies" << m_zombies.size() << "bullets" << m_bullets.size(); } }参数说明:m_debugMode可以在命令行加--debug参数来控制,默认false。这样你不必删日志,只有演示时打开。使用统计信息能看到:如果僵尸数量一直不增加,可能是waveQueue中时间没有到;如果子弹数量持续增长,说明撞击后没有正确remove。
5.2 调整植物花费和僵尸速度:一张参数表讲清楚平衡
课程设计打分者很看重可玩性。可玩性来自数值,不是代码。把关键数值抽出到config.h,表格呈现:
| 参数 | 原版参考值 | 推荐课设值 | 说明 |
|---|---|---|---|
| 阳光初始值 | 50 | 150 | 让玩家开局能立刻种向日葵 |
| 向日葵花费 | 50 | 50 | 保持简单 |
| 向日葵产阳光间隔 | 12秒 | 10秒 | 加快节奏,避免演示等待 |
| 豌豆射手攻击力 | 20 | 25 | 提高通关效率 |
| 普通僵尸速度 | 0.2px/tick | 0.25px/tick | 40%加速,接近现实手速 |
你会发现直接套原版数值反而拖沓,课堂演示时3分钟一轮比较合适。调参后一定要跑一遍完整流程,记下击破了几个僵尸、丢了几朵植物,把测试结果写进课设报告,这比代码本身更能体现工程意识。
5.3 当僵尸数量增多时的碰撞优化
如果课上测试时超过80个僵尸,每帧的交集运算开始占用可观CPU,帧率下降。优化方向有两个:一是利用QGraphicsItem的boundingRect,Qt自带会做空间索引用起来。二是按行分桶,把僵尸按row放入QVector<Zombie*> m_zombiesByRow[5],碰撞检测时子弹只需要查询对应行桶,而不是全量遍历。第三种更简单的优化是把僵尸碰撞矩形缩窄,减少误命中,同时让CPU忙于矩形相交的几率低一些。
在Qt中可以利用QGraphicsScene::items(const QRectF &rect, Qt::IntersectsShape),它返回该矩形区域内的所有item。这里的坑是items不会区分类型,所以还是要用qgraphicsitem_cast过滤僵尸。如果追求速度,还是用自己的按行容器更好——代码更直白:
QVector<Zombie*>& rowZombies = m_zombiesByRow[bullet->row()]; for (Zombie *z : rowZombies) { if (z->sceneBoundingRect().intersects(bullet->sceneBoundingRect())) { // apply damage break; } }这样每次碰撞只需遍历同一行的僵尸,数量通常不超过10个,性能几乎没有任何压力。
6. 课程设计答辩的演示顺序与代码讲解技巧
6.1 演示流程先跑通,再谈代码
现场演示不要从冷启动开始。进门前先把程序运行到某个稳定状态存一个存档点,如果课程设计支持读档,这是最从容的方式;如果没有存档功能,提前在主菜单放一个“快速开始”按钮。演示时按“种植向日葵→阳光收集→种植豌豆→僵尸出现→战斗结束”的顺序走,时间控制在2分钟以内。每一段最好都有可见的变量变化,比如阳光数、僵尸剩余血量,这些都是老师能直观看到的东西。
6.2 讲清楚三个“技术关键词”
答辩时老师不一定有时间看完全部源码,但会对这样几个关键词有印象:多态、事件循环、碰撞检测。你可以打开GameEntity的类图,先说sprite、hp、updateByTick是公有的行为,再点开Peashooter,指出它重写paint,所以每个实体自己决定画成什么。接着用箭头在onTick和updateByTick之间绕一圈,说明主循环由QTimer驱动。碰撞部分打开handleBulletCollisions,说明你做了同行过滤。全程不要念代码,而是像讲架构一样,配合鼠标勾选关键行,老师会认为你确实理解了工程结构。
6.3 两个给课程设计加分的实用小功能
如果还有余力,我给两个低成本功能。第一个是用QSettings保存最高分与关卡进度,适当展示ini文件内容;第二个是用QShortcut设置键盘快捷键,比如空格键暂停,数字键1/2/3快速种植不同植物。这两个功能实现起来都在30行以内,但它们能证明你具备真实Qt开发习惯,而不仅仅是会用控件。最后的演示重点一定是稳定性和手感,宁可少一个功能,也不要出现演示到一半僵尸卡住这种翻车现场。
本文还有配套的精品资源,点击获取