
简介一份面向计算机相关专业在校生与初学者的Qt/C课程设计源码实现简易版植物大战僵尸游戏。项目覆盖主界面渲染、植物种植、子弹发射、僵尸生成与移动、阳光收集等核心玩法界面与逻辑分包组织结构便于理解可用作课程设计、毕业设计或项目初期演示也适合在此基础上按需扩展功能。压缩包共37个文件以17个cpp源文件和16个h头文件为主内含pro工程配置、qrc资源与README说明植物、豌豆、僵尸、阳光、冷却等模块按类拆分线程控制与音效处理也单独放置整体仅32KB轻量且便于快速阅读调试目录结构清晰。目前已有536人学习下载代码已测试运行通过功能可用解压后可直接编译运行完整工程便于课程设计答辩演示也适合二次开发扩展。1. 课程设计基于Qt和C框架编写的简易植物大战僵尸游戏源码.zip值得花一个周末吃透看到这个标题还愿意点进来的多半是正在为C课程设计发愁的人命令行通讯录、图书管理系统写得再好答辩时也讲不出十分钟。这个项目把“植物大战僵尸”的核心循环——阳光产出、植物种植、僵尸生成、子弹碰撞——全部收进一个Qt Widgets工程里用类结构去映射玩法用信号与槽去接事件代码量不大但五脏俱全。适合大二大三相承课设也适合Qt初学者把官方demo换成这套更完整的源码去捋清对象生命周期和绘图流程。如果你只想“跑起来截图交差”这方向反而绕但如果你想在答辩时让老师看到你对框架、封装、游戏循环的理解这一套源码值得反复看三遍。2. 为什么是Qt不是别家选型理由拆解与工程内部分层2.1 Qt Widgets还是Qt QML课程设计最不会被答辩追问的选型理由植物大战僵尸这类2D塔防摆在面前的第一条路就是Qt里的两套UI体系Qt Widgets和Qt QML。网上不少人推荐Qt QML理由是动画顺滑、界面代码短甚至有人把它和Qt MVVM框架扯到一起写大架构。我的观点很直接课程设计用Qt Widgets别上QML。QML的Canvas和Behavior做僵尸摇摆确实快但答辩老师一问“C侧怎么跟QML通信”你就得解释Q_PROPERTY、contextProperty、信号槽桥接复杂度翻一倍。Qt Widgets把逻辑用C类写完回调逻辑全在一处调试时一个断点能看完全部状态。还有一个现实原因大部分课程设计环境是Qt 5.15.X加一个MSVC或者MinGW套件Qt Widgets的兼容性最稳。QML如果碰到显卡驱动问题花一下午查环境都有可能。用QWidget做游戏视图重写paintEvent自己画植物格子、僵尸血条、子弹移动每一帧的状态都在掌控中这种“黑匣子暴露在你面前”的感觉恰恰是答辩时最有底气的资本。2.2 源码里的“框架”二字指什么八类模块各司其职标题里最容易被人忽略的词是“框架”。它不是指某个现成的应用框架而是这套源码自带的工程骨架。拿到手你基本会看到这样的分工模块职责对应游戏机制main.cpp启动入口创建游戏窗口程序入口GameController维护游戏状态、计分、胜负游戏规则GameWidget绘图画布、鼠标事件、定时器画面与交互Plant基类植物属性、冷却、发射逻辑豌豆射手/向日葵Zombie基类血量、移动、啃食逻辑普通僵尸/路障僵尸Bullet类子弹属性、伤害、方向豌豆子弹SunObject类阳光的产生、点击收集经济系统resources.qrc打包图片和音频贴图与音效这个分层思路比游戏本身更重要玩法逻辑在Controller渲染交互在Widget实体对象是普通C类。答辩时你只要说一句“我把每个可独立测试的对象拆成一个类”老师就能看出你不是直接复制粘贴的。很多初学者会犯的错是把所有代码堆进一个QMainWindow里最后updateGame函数写两百行排错时根本无从下手。按这套模块拆至少改僵尸速度、加新品种植物时不需要动绘图代码。2.3 用信号与槽管理游戏事件而不是全局变量和if嵌套Qt框架里最值得写进答辩讲稿的就是信号与槽。很多课设项目一到“僵尸死亡后加一分”“植物被啃完界面要刷新”就开始用全局变量传状态代码越写越像蜘蛛网。Qt的做法是事件发生方只管发信号关心这件事的类自己决定要不要响应。// 子弹击中僵尸由Controller发出信号 class GameController : public QObject { Q_OBJECT public: void onBulletHitZombie(Bullet* bullet, Zombie* zombie) { zombie-takeDamage(bullet-damage()); if (zombie-hp() 0) { zombie-setDead(true); emit zombieKilled(zombie-rewardSun()); } } signals: void zombieKilled(int sunValue); void gameOverByZombie(); void waveChanged(int currentWave); };关键点在于emit只负责“通知”GameWidget可以在构造时把僵尸死亡信号连到自己的刷新槽函数上GameController完全不需要知道界面长什么样。这样如果以后你想把界面换成绩单显示或日志输出只需要新增一个连接。信号与槽机制本身有轻微性能损耗但一个简单游戏一帧也就几十个对象损耗可以忽略换来的是结构清晰度。答辩现场如果有老师问“你这里为什么不用回调函数”你就可以回答回调在跨线程和对象销毁时容易变成悬垂指针信号与槽在连接断开上更安全。3. 从空工程到能跑的最小demo手把手代码落地3.1 最小类继承设计从QWidget到游戏画布只有三步拿到源码先别急着全盘看懂我建议按“画得出→动得起来→打得死人”三步还原。第一步只做一个能画草地的QWidget子类。Qt里所有窗口都可以当作画布关键是重写paintEvent用QPainter绘制背景和格子线。这里有个很容易绕弯的点不要把每个植物都设计成QLabel放上去那会让你后面处理碰撞检测时到处找geometry。正确做法是自定义数据结构存在内存里paintEvent里统一绘制。// GameWidget.h #ifndef GAMEWIDGET_H #define GAMEWIDGET_H #include QWidget #include QList #include Plant.h #include Zombie.h #include Bullet.h class GameController; class GameWidget : public QWidget { Q_OBJECT public: explicit GameWidget(QWidget* parent nullptr); void setController(GameController* controller); protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void timerEvent(QTimerEvent* event) override; private: void updateGameFrame(); void drawGrass(QPainter painter); void drawPlants(QPainter painter); void drawZombies(QPainter painter); void drawBullets(QPainter painter); QListPlant* m_plants; QListZombie* m_zombies; QListBullet* m_bullets; GameController* m_controller; int m_gameTimerId -1; }; #endif构造时启动定时器常用的做法是用QTimer配合事件循环也可以用原生timerEvent。用timerEvent少include一个头文件但QTimer更灵活可以在游戏暂停时stop。我这里倾向QTimer因为它能挂在任意对象身上lambda槽写起来方便。最终选择哪种取决于你源码里原本怎么组织不需要两边都写。3.2 游戏主循环QTimer驱动帧更新与对象状态推进游戏循环是这个项目的发动机。植物大战僵尸不像贪吃蛇那样等玩家按键才动它需要每隔几十毫秒推进一次僵尸位置、子弹飞行、阳光倒计时。Qt里没有专门的GameLoop类你要自己用QTimer模拟。每秒30帧就是33毫秒回调一次帧率太低僵尸一卡一卡帧率太高CPU占用翻倍。我这个项目里会把m_tickMs定义成33留一个常量方便改。// 构造函数中启动游戏循环 GameWidget::GameWidget(QWidget* parent) : QWidget(parent), m_controller(nullptr) { setFixedSize(GRID_COLS * CELL_W, GRID_ROWS * CELL_H TOOLBAR_H); QTimer* timer new QTimer(this); connect(timer, QTimer::timeout, this, GameWidget::updateGameFrame); timer-start(m_tickMs); // m_tickMs 33约30fps } void GameWidget::updateGameFrame() { // 1. 自然生成阳光按累计时间触发 m_sunAccumulator m_tickMs; if (m_sunAccumulator m_sunIntervalMs) { spawnSun(); m_sunAccumulator - m_sunIntervalMs; } // 2. 植物按射速发射子弹每棵植物有独立冷却 for (Plant* plant : std::as_const(m_plants)) { if (plant-isReadyToShoot()) { Bullet* bullet new Bullet(plant-bulletSpawnPos(), plant-bulletDamage()); m_bullets.append(bullet); plant-resetShootCooldown(); } } // 3. 子弹向右侧移动僵尸向左侧移动 for (Bullet* bullet : std::as_const(m_bullets)) { bullet-setX(bullet-x() m_bulletSpeed * m_tickMs / 1000.0); } for (Zombie* zombie : std::as_const(m_zombies)) { zombie-setX(zombie-x() - m_zombieSpeed * m_tickMs / 1000.0); } // 4. 统一处理碰撞 handleCollisions(); // 5. 清理死亡对象 cleanDeadObjects(); // 触发重绘paintEvent会重新绘制全部对象 update(); }这里有两个值得答辩细讲的参数一个是m_bulletSpeed与m_tickMs的关系子弹每帧位移如果直接用固定像素切换帧率时速度会变化我习惯把速度定义成“每秒移动像素数”再乘上tick时间换算这样改帧率不影响手感。另一个是m_sunAccumulator它用累加器模式而不是计数器递减避免定时器累积误差。真实计时器可能在系统繁忙时偶尔延迟几十毫秒用累加器能自动消化这些误差比单纯数帧数稳定得多。3.3 从鼠标点击到种植网格坐标换算与合法性判断植物大战僵尸的种植交互本质是“窗口像素坐标”到“逻辑网格坐标”的换算。鼠标点击在屏幕上是(x, y)而格子是行列号中间隔着一层单元格宽度和高度的除法。新手最容易翻车的是忘记减去顶部工具栏的高度导致点击第一排却种到第二排。void GameWidget::mousePressEvent(QMouseEvent* event) { if (m_gameOver) return; int x event-pos().x(); int y event-pos().y() - TOOLBAR_H; // 工具栏偏移 // 点中顶部工具栏或超出边界则忽略 if (y 0 || x 0 || x GRID_COLS * CELL_W || y GRID_ROWS * CELL_H) { return; } int col x / CELL_W; int row y / CELL_H; // 点击范围贴近格子的算格子否则可能误触 if (m_selectedPlantType PlantType::None) { // 未选中植物可能是在采阳光 emit clickForSun(QPoint(col * CELL_W, row * CELL_H)); return; } if (m_grid[row][col]) { // 已有植物提示不可种植 return; } emit plantSelectedToPlace(m_selectedPlantType, row, col); m_selectedPlantType PlantType::None; }这套代码的关键是“选中植物后再点击格子”。实际项目里通常有一个工具栏区域显示卡片卡片被点击后记录m_selectedPlantType然后等待鼠标点击地图。要注意的是QMouseEvent的坐标是相对当前控件的如果你的游戏窗口嵌在更大的界面里先event-pos()再用mapTo如果没有特殊布局就够用了。阳光折叠的点击收集同样走这个流程只是不需要网格判定直接用QRectF::contains判断点击位置是否落在阳光贴图区域内。3.4 碰撞检测与子弹生命周期谁负责删对象早期demo里最容易出Bug的就是对象清理僵尸被子弹打死但子弹还在飞行植物被啃光僵尸还站在原地继续啃空气。这里我要先说一句血泪经验不要在遍历QList的循环里直接delete元素迭代器会失效下一秒就崩给你看。统一做法是把死亡对象标记出来等遍历结束再统一清理。void GameWidget::handleCollisions() { // 子弹与僵尸的碰撞 for (Bullet* bullet : std::as_const(m_bullets)) { if (bullet-isDead()) continue; QRectF bulletRect bullet-collisionRect(); for (Zombie* zombie : std::as_const(m_zombies)) { if (zombie-isDead()) continue; if (bulletRect.intersects(zombie-collisionRect())) { zombie-takeDamage(bullet-damage()); bullet-setDead(true); // 一颗子弹只命中一个僵尸 if (zombie-hp() 0) { zombie-setDead(true); } break; } } } // 僵尸与植物的碰撞僵尸停下啃咬 for (Zombie* zombie : std::as_const(m_zombies)) { if (zombie-isDead()) continue; for (Plant* plant : std::as_const(m_plants)) { if (plant-isDead()) continue; if (zombie-collisionRect().intersects(plant-collisionRect())) { zombie-setEating(true); // 停止移动 plant-takeDamage(zombie-damagePerBite()); if (plant-hp() 0) { plant-setDead(true); zombie-setEating(false); } break; } } } }cleanDeadObjects函数里只要遍历三张表把m_plants[m_plants.size() - 1]等尾元素与死亡对象交换后pop_back避免删中间元素导致大量移动。如果对象是由new创建的别忘了delete但注意父对象链如果一个对象设置了QObject父对象delete会由父析构统一执行如果你又手动delete就可能双重释放。我的经验是实体类不继承QObject让GameWidget统一持有并统一删除这样生命周期最直观。4. 课程设计最常翻车的六个坑现象、原因、解决4.1 依赖路径报错:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets does not exist现象用Qt Creator打开源码后构建输出面板第一行就出现上面这种以:-1: error: dependent开头的红色错误项目完全无法编译。原因这个报错不是代码问题是Qt Creator的构建配置里套件与项目文件包含了错误的依赖路径。常见诱因包括压缩包解压后路径含中文或空格、别人提交的.pro文件里写死了绝对路径、或者本机装的Qt版本和源码作者用的完全不同。MSVC2019_64套件只认对应编译版本的Qt库。解决先看左侧项目模式里的Kits列表确认当前用的编译器是MSVC还是MinGW然后打开.pro文件检查里面有没有INCLUDEPATH或DEPENDPATH指向了Qt安装目录。正常Qt工程不需要手动添加Qt自身include路径那些路径由套件自动注入。如果.pro里出现了类似D:/Qt/5.15.2/...的写死路径删掉后重新qmake。最后把整个项目放到纯英文目录下再试一次中文路径在MSVC套件下经常让依赖解析失效。4.2 图片和音效全部空白qrc资源系统加载失败现象程序编译通过、窗口弹出来了但草坪是黑的植物贴图不显示点击也没有音效。原因资源没有被打进可执行文件。Qt的资源分两种路径一种是硬盘绝对路径一种是qrc资源路径:/images/peashooter.png。课程设计被网上素材坑过的人很多直接把图片路径写在代码里程序一换目录就找不到文件还有的人qrc文件里路径前缀写错比如实际是:/res/image代码里写:/image。解决右键项目添加Qt Resource File把存放贴图的目录加进去。代码里统一用冒号开头访问不要用相对路径。另外注意qrc文件的编码如果图片文件名是中文在某些编译器下会乱码建议所有素材改成英文命名。4.3 游戏运行十几秒后随机崩溃对象被提前delete现象可以正常种下几棵植物僵尸一出来子弹一碰到僵尸程序退出并报Segmentation fault。原因典型生命周期管理失误。比如子弹的碰撞检测里调用了delete bullet但同一轮循环后面又访问bullet或者僵尸被清掉后GameController里还保留着指向僵尸的指针下一次刷新时又用它。Qt智能指针确实用起来省心但在普通C容器里混用裸指针和智能指针反而更复杂。解决所有从容器移除的对象统一走markDead标记循环结束后在cleanDeadObjects里统一delete并removeOne。如果对象本身是QObject子类可以考虑deleteLater()它会等事件循环回到安全点再释放避免“正在处理信号时对象被销毁”。4.4 报错“Qt版本与编译器不匹配”找不到MSVC2019_64工具链现象代码看起来没问题但构建时报cannot find -lQt5Widgetsd或unknown option -Wl这样的链接错误。原因Qt安装时选择的编译器版本跟当前套件不匹配。比如机器上装了支持MSVC2019_64的Qt 5.15.2但编译器组件没完全安装或者反过来MinGW套件尝试链接MSVC编译的Qt库。解决如果坚持用MSVC套件打开Qt安装目录维护工具勾选对应版本的MSVC2019_64组件。如果只是课设只要能跑最快是切换到MinGW套件把.pro里的QT widgets确认无误后直接重新构建。两种编译器生成的库文件格式不同混用必炸这个没有后悔药看清套件再动手。4.5 游戏速度忽快忽慢QTimer误差没有补偿现象植物射速一开始正常运行两分钟后明显变快或者画面一顿一顿。原因QTimer的触发间隔不是绝对精确的尤其在后台有垃圾回收或系统负载时实际触发间隔会比设定值大。如果你用“每次timeout就让冷却时间减1”这种计数方式积累误差会越滚越大。我见过把m_tickMs设成10毫秒的系统一卡定时器积压timeout连续触发多次植物瞬间射出满天子弹。解决用QElapsedTimer记录真实流逝时间每次game frame计算elapsedTime m_elapsedTimer.restart()然后所有速度都按elapsedTime换算。代码里就把m_tickMs当成最大步长实际移动距离是速度乘以真实毫秒数。游戏想要稳定宁可画面掉帧也不能让逻辑时间凭空跳跃。4.6 高分屏下植物位置点击不准devicePixelRatio问题现象在2K或4K屏幕上运行植物被种下去的位置跟鼠标点的地方差出一截但在普通1080p下正常。原因Qt在支持高DPI缩放的平台上逻辑坐标和物理像素坐标有比例关系。如果窗口没有设置setAttribute(Qt::AA_EnableHighDpiScaling)或者代码里用了event-globalPos()再做局部坐标转换就会拿物理坐标去套逻辑网格。解决在main.cpp的QApplication创建之前设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);和setAttribute(Qt::AA_UseHighDpiPixmaps);然后事件处理一律用event-position()而不是event-pos()。Qt 5.15里建议统一用event-position().toPoint()Qt 6里pos()已经是浮点了。这个坑不遇到时觉得是玄学遇到后一查DPI心里就清楚了。5. 参数设计植物平衡、难度曲线和存档验证5.1 五类必调参数从数值上让“简易版”也有手感很多课程设计源码能玩但不好玩就是因为在直接堆功能时忘记调数值。植物大战僵尸的底层平衡藏在五个参数组里生产节奏、攻击节奏、僵尸强度、空间容错、时间压力。你可以在一开始全用常量但在答辩演示前一定要把这些集中到一个配置区域。参数组典型默认值作用调整逻辑阳光生成间隔5000~7000ms决定经济节奏缩短到3000ms游戏偏简单拉长到9000ms需要更多向日葵植物射速每1.5s一发决定防线强度豌豆升到0.8s会使僵尸很难推进僵尸移动速度15~30px/s决定关卡压力波动超过50px/s会感觉瞬移不真实僵尸血量80~200决定子弹命中次数普通僵尸3颗子弹死路障僵尸8颗子弹死这个比例比较合理关卡波次间隔10~20s决定是否手忙脚乱间隔越短越紧张适合做教学关和地狱关把这些值定义成类的static constexpr成员或配置文件里读入答辩时现场改一个参数演示游戏变化效果远比对着代码念要好。“我能看清每个数值怎么影响手感”这句话是老师最愿意听到的。5.2 难度曲线用代码而不是硬编码表简易版常见的简陋做法是每一关硬编码一波僵尸列表代码写起来累演示时又没法动态变化。更好的方式是定义一个“波次生成规则”用当前波次号驱动生成间隔、僵尸血量、数量上限。// GameController.cpp void GameController::startNextWave() { m_wave; // 波次越高僵尸生成越快但设一个下限防止变成高压无解 int spawnIntervalMs qMax(1500, 7000 - m_wave * 300); if (m_wave % 5 0) { // 每五波出现一次强化血量和数量一起涨 m_zombieHp qMin(500, 80 m_wave * 15); } emit waveChanged(m_wave); }用qMax/qMin把数值钳制在一个合理范围内防止后期失控。做曲线时也别追求数学上精确的指数函数课程设计最怕公式太复杂以至于自己解释不清。你可以在答辩时说“这个曲线是线性的但每5波给一个断点让玩家感受到阶段性挑战。”一句话就能解释完老师也满意。5.3 存档验证最高波次和阳光数用QSettings落地存档功能是很多“简易版”源码故意不做的你做了就是加分项。用QSettings存键值对比手写JSON简单得多而且跨平台路径由系统管理不需要自己创建配置文件。// 保存进度 QSettings settings(MyUniversity, PvZCourse); settings.setValue(maxWave, m_controller-currentWave()); settings.setValue(totalSun, m_controller-totalSunCollected()); settings.sync(); // 读取进度 int maxWave settings.value(maxWave, 1).toInt(); int totalSun settings.value(totalSun, 0).toInt();QSettings的构造参数第一个是组织名第二个是应用名具体到Windows就是注册表或ini文件Linux下是隐藏配置文件你完全不用关心存储路径。Qt 5.15和Qt 6在这套API上没有破坏性变化源码拿来直接用。演示的时候先打一局关掉重开最高波次还在这就是实打实的功能点。5.4 进入Qt界面的输入细节别再傻等paintEvent过度绘制最后一个常被忽视的调优点不是功能而是输入体验。有些源码里植物射击检测每隔一帧就遍历全图查找目标这在小规模没问题但如果后续加植物种类同一行同时存在5棵植物和3个僵尸每帧重复计算就会卡。最简单的优化是每个植物在种植时把行号绑定射击时只遍历同行的僵尸这个字段在Plant类构造时就填好。// Plant构造时记录行号避免全图僵尸列表遍历 Plant::Plant(int row, int col, PlantType type) : m_row(row), m_col(col), m_type(type), m_shootCooldownMs(0), m_hp(100) {} bool Plant::isSameRow(const Zombie* zombie) const { return zombie-row() m_row; }同行的判断还能降低误伤逻辑复杂度因为PVZ规则是同一行的子弹只打同一行的僵尸。界面上再补充一个当前选中的植物高亮框用drawRect画个边框即可这层细节会让整个游戏看起来比“源码”更像“产品”。6. 从“跑起来”到“能答辩”三个验证技巧和一个加分习惯源码能跑只是开始课设评分看的是“你究竟懂多少”。第一个验证习惯是给自己做一次“最小化复盘”把游戏每一步的输出来到控制台比如僵尸生成时打印行号子弹命中时打印伤害。不要只依赖肉眼观察因为有些Bug是概率性的肉眼很难复现。调试时按F10单步跟一遍handleCollisions你会发现对象状态在每一帧都合乎预期。第二个验证思路是格式化设计限制游戏区域固定大小用setFixedSize代替resize窗口不可拉伸能避开大量坐标适配问题。同时把GRID_COLS、GRID_ROWS这些常量写在类头部注释里别人一眼能看到五列九行、单元格80x100这种边界参数。答辩现场老师会用鼠标拉窗口、快速点击、连按空格等虐待式操作用固定的棋盘布局能稳定扛住。第三个技巧是利用Qt Creator自带的性能分析器不用额外配置。运行程序后切到Profiler模式观察QWidget::paintEvent的耗时。如果单帧绘制超过5毫秒通常意味着你把太多无用绘图塞进了paintEvent比如每一帧重新创建QPainter对象、加载QPixmap等。把贴图在构造时预先load到成员变量里paintEvent只做drawPixmap性能立刻上来。我自己的习惯是给每个类写一个debugDescription方法返回当前状态字符串比如植物类型、血量、冷却剩余。这个小工具在答辩演示时按下快捷键就能在控制台看到整张游戏地图的快照。它不占界面空间但能证明你对整个数据结构的掌握程度。植物大战僵尸这种游戏最难的不是画图而是状态同步植物血量和僵尸位置每帧都在变你能把状态用文本打出来碰到任何问题都有底。至于加分习惯就是养成“先看对象生命周期再改逻辑”的排查顺序。每次游戏崩了先在cleanDeadObjects里加断点看谁在释放。Qt的项目里90%的崩溃都是重复delete和用悬垂指针剩下10%才是逻辑错误。把这份源码当作一个活的调试练习跑通它、改慢它、改快它再去加一个新植物。真到了答辩那天你会发现自己不是在背代码而是在讲一个自己设计过的系统。希望帮到你。本文还有配套的精品资源点击获取