
简介面向计算机相关专业课程设计与毕业设计的Qt与C简易植物大战僵尸游戏源码包适合作为课程大作业、毕业设计或编程进阶的起步项目。资源共三十七个文件以十七个C源文件与十六个头文件为主另含Qt资源文件、工程配置与说明文档压缩包仅三十二KB结构紧凑便于导入Qt Creator直接编译运行。项目内的游戏地图、植物卡片、僵尸线程、阳光分数、音效控制等模块划分清晰能够帮助学习者理解面向对象设计、事件驱动、多线程以及图形视图框架的综合运用这样的模块化设计也提升了代码的可读性与可维护性。已有五百三十五人学习下载源码已经过测试运行成功可放心使用若基础较好也可在此基础上扩展新植物、关卡波次或动画效果适合入门游戏开发并快速完成课程项目实践。1. 课程设计里的 Qt 植物大战僵尸难点从来不在绘图拿到“基于 Qt 和 C 框架编写的简易植物大战僵尸游戏源码.zip”这类压缩包的人大多不是没见过代码而是想知道一个课设项目为什么非得这么分层、这么调度。这份源码的核心通常很直接用 Qt 5 的 QGraphicsView 框架搭场景把向日葵、豌豆射手、僵尸各自封装成继承 QGraphicsObject 的 C 类再用 QTimer 驱动整个游戏逻辑。它不会复刻原版全部玩法只要做到种植、出怪、发射豌豆、判定胜负这四条主线能闭环就算合格。比起用 QWidget 自绘这类 Qt 游戏源码在对象管理上更接近真实项目值得从业者看的其实是它的坐标换算、碰撞判定和帧循环设计而不是界面本身。2. 先定 C 对象模型QGraphicsView 框架下的精灵继承结构2.1 课程设计游戏为什么普遍选 QGraphicsView 而不是自绘很多人在打开源码后第一眼看到QGraphicsScene、QGraphicsItem误以为这是项目自己封装的一套框架实际上是 Qt 官方提供的绘图框架。它和你在QWidget::paintEvent里拿QPainter把所有角色一张张画出来最大的区别是省掉了三件重复劳动场景坐标系、图元命中测试items(pos)、对大量可移动对象的批量管理。课程设计排期通常只有两到四周你需要的是先让游戏跑起来而不是先造一套移动、碰撞、选中的轮子。如果在课设里直接用 QWidget 自绘所有精灵的位置都要自己用 QRect 维护鼠标点选要自己遍历移除对象还要手动处理重绘区域任何一个角落出问题答辩演示都会卡在“为什么这个僵尸点不到”上。QGraphicsView 则把精灵当成独立QGraphicsItem每个对象有独立的setPos、boundingRectQt 在内部做重绘裁剪。另一个容易被忽略的点是项目里的 Zombie 继承的往往是QGraphicsObject而不是QGraphicsItem目的就是让每个游戏对象天然携带 QObject 信号槽能力比如僵尸死亡时发射zombieDied信号由场景统一结算分数。提示在 Qt 开发里QGraphicsItem只是一个纯绘图条目不带事件循环和信号槽QGraphicsObject才是带 QObject 能力的版本。课设代码里如果用 Item 反而要自己回传事件通常会麻烦不少。2.2 三类 C 对象的分工基类只做矩形和绘制入口常规的做法是把游戏对象分成三层一个GameItem基类提供行列号、HP、绘制接口Plant和Zombie继承它各自实现自己的行为LevelScene这个QGraphicsScene子类负责持有对象列表并做碰撞结算。基类代码大致是这样一个结构class GameItem : public QGraphicsObject { Q_OBJECT public: explicit GameItem(QGraphicsItem* parent nullptr) : QGraphicsObject(parent) {} int row() const { return m_row; } int col() const { return m_col; } void setGridPos(int row, int col) { m_row row; m_col col; } protected: // 告诉场景这个精灵占用的矩形碰撞和重绘都依赖它 QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget nullptr) override; int m_row 0; int m_col 0; int m_hp 100; };boundingRect()是这套框架里最关键的返回值它决定了场景认为这个精灵“占了多大地方”。很多课设里的植物显示在草地上但点击判定却偏了几个像素问题基本都是boundingRect()返回的矩形和绘制内容不对齐。paint()只负责画不要在里面去修改数据否则当 Qt 因窗口遮挡触发重绘时对象的血量可能被误改。子类的差异集中在“行为和冷却”上。课程设计里最常见的需求是豌豆射手按间隔发子弹、向日葵产阳光、僵尸沿直线前进并啃植物代码里可以用两个子类把这三种行为收敛起来class Plant : public GameItem { Q_OBJECT public: enum PlantType { Sunflower, Peashooter, CherryBomb }; PlantType type() const { return m_type; } bool canShoot(qint64 nowMs) const { return m_type Peashooter nowMs - m_lastShotMs m_fireIntervalMs; } void recordShot(qint64 nowMs) { m_lastShotMs nowMs; } private: PlantType m_type Peashooter; qint64 m_lastShotMs 0; int m_fireIntervalMs 1500; }; class Zombie : public GameItem { Q_OBJECT public: void setSpeed(qreal pxPerTick) { m_speed pxPerTick; } qreal speed() const { return m_speed; } bool isEating() const { return m_eating; } void setEating(bool eating) { m_eating eating; } int attackIntervalMs() const { return m_attackIntervalMs; } private: qreal m_speed 0.6; // 每个逻辑帧移动的像素数 bool m_eating false; int m_attackIntervalMs 800; };这里有个容易踩的速度坑m_speed以“每逻辑帧”为单位而不是“每秒”。因为 QTimer 固定 50ms 触发一次逻辑帧0.6 像素每帧相当于每秒 12 像素。如果把速度理解成 m/s再把setInterval从 50ms 改成 20ms 后去重调速度很容易出现僵尸突然变慢或快到贴脸的情况。调参时只改一个位置这是对象模型的收益。下表是课设里类与职责的常用划分类主要成员容易用错的地方GameItemrow/col、hp、boundingRect把绘制内容画到 boundingRect 之外Planttype、射击冷却把冷却逻辑放在 paint 里Zombiespeed、isEating、攻击间隔用像素坐标当作行号去对碰撞LevelScene植物网格、对象列表在遍历列表时直接删除元素2.3 谁负责“咬”和“被打”把结算放进场景而不是对象自身一个新手很容易写出的实现是让 Zombie 在移动的advance()方法里直接去减Plant::hp。这个写法在“只有一个僵尸、也不移除对象”的演示里能跑但只要出现两个僵尸同时咬一棵植物就会出现双重扣血或遍历列表时对象已经失效的问题。常见做法是让僵尸只设置isEating(true)并记录当前啃咬目标每个逻辑帧结束时由LevelScene统一调用resolveFights()根据僵尸的目标做一次伤害结算。这就是为什么源码里通常有一个独立的checkCollisionAndDamage()方法。它不负责绘制、不负责移动只负责把“接触”和“伤害”变成两件互不干扰的事情。对象各自上报状态场景做最终裁决这个思路在整个 Qt 小游戏源码里都适用。3. 种植模块的坑把点击坐标换算成棋盘格坐标3.1 坐标系的三种身份视图坐标、场景坐标、格子坐标在 Qt 的 QGraphicsView 框架里鼠标点击至少经过两次坐标变换QMouseEvent::pos()是视图坐标需要先mapToScene得到场景坐标再换算为行和列。课设源码里最常出现的问题是点击植物栏应该种在第五格结果种到了第一格就是因为没有区分“场景坐标”和“格子坐标”。棋盘格的常量建议直接写成编译期常量放在场景类的头文件里后面所有换算共用一组数字constexpr int kGridRows 5; constexpr int kGridCols 9; constexpr int kGridCellWidth 80; constexpr int kGridCellHeight 96; constexpr QPointF kPlayFieldTopLeft(25, 140); QPointF gridOrigin(int row, int col) { return QPointF(kPlayFieldTopLeft.x() col * kGridCellWidth, kPlayFieldTopLeft.y() row * kGridCellHeight); } bool sceneToGrid(const QPointF scenePos, int* rowOut, int* colOut) { QPointF local scenePos - kPlayFieldTopLeft; if (local.x() 0 || local.y() 0) return false; int col static_castint(local.x()) / kGridCellWidth; int row static_castint(local.y()) / kGridCellHeight; if (row kGridRows || col kGridCols) return false; *rowOut row; *colOut col; return true; }kGridCellWidth不是背景图片的像素宽度除以列数得到的估算值而应该是“可种植区域的实际宽度除以列数后向下取整”否则点击第 9 格时会越界。kGridRows 5、kGridCols 9对应原版草坪的大致格数换成 6 行 10 列也可以只要保证gridOrigin和sceneToGrid使用的是同一套常量即可。kPlayFieldTopLeft一般取背景图左上角第一个格的左上角坐标推荐把它定义成QPointF而不是两个独立 int避免在传参时把 x、y 写反。3.2 种植判定与 setPos 偏移为什么植物总差半格场景的鼠标事件里完整流程是点击时取scenePos换算成格子判断该格是否已经有植物再生成新植物并加到场景。常见代码如下void LevelScene::mousePressEvent(QGraphicsSceneMouseEvent* event) { QPointF scenePos event-scenePos(); int row 0, col 0; if (!sceneToGrid(scenePos, row, col)) return; // 点在草坪外忽略 if (m_plantGrid[row][col] ! nullptr) return; // 该格已有植物 Plant* plant createPlant(m_pendingPlantType, row, col); QPointF center gridOrigin(row, col) QPointF(kGridCellWidth / 2, kGridCellHeight / 2); plant-setPos(center - plant-boundingRect().center()); addItem(plant); m_plantGrid[row][col] plant; QGraphicsScene::mousePressEvent(event); }plant-setPos(center - plant-boundingRect().center())这行是整个种植逻辑里最容易抄错的地方。setPos设置的是图元左上角所在的场景位置而 gridOrigin 给出的是一格的左上角。如果不减去boundingRect().center()植物会整体向右下偏移看起来就像被“平移”了半个格子。更麻烦的是碰撞检测也拿boundingRect来做所以尽管视觉上植物在格子里实际判定矩形已经压到下一格去了。createPlant是一个工厂方法内部根据m_pendingPlantType生成不同的植物子类。课程设计如果做了选择栏通常是在点击卡片时更改这个成员变量没做选择栏就默认豌豆射手也能跑通演示。3.3 碰撞判定按行进方向做轴向比较不要迷信 intersects植物和僵尸的碰撞常见做法不是调用mapRectToScene(zombie-boundingRect()).intersects(...)而是只比较“僵尸的左边界”和“植物的右边界”。这样做的原因是场景里还有豌豆子弹和其他装饰物用矩形相交容易把“子弹打到僵尸”和“僵尸咬到植物”混在一起。轴向判定的写法是bool isZombieTouchingPlant(const Zombie* zombie, const Plant* plant) { if (zombie-row() ! plant-row()) return false; qreal zLeft zombie-scenePos().x() - zombie-boundingRect().width() / 2; qreal pRight plant-scenePos().x() plant-boundingRect().width() / 2; return zLeft pRight; }这个函数只判断僵尸是否碰到了植物的右边界逻辑上假设僵尸从右侧沿 X 轴负方向前进。scenePos().x()减去宽度的前半段得到图元实际左边缘如果这个左边缘已经越过植物的右边缘说明两者在水平方向发生了重叠再加上同行的前提就是一次有效接触。相比矩形相交它的好处在于不需要处理 Y 轴的冗余判断也不会因为植物贴图里有一块透明区域而提前触发。4. 用 QTimer 驱动主循环豌豆冷却、逻辑帧与资源打包4.1 固定步长逻辑把渲染交给 Qt把节奏留给自己一个 Qt 植物大战僵尸课设的“主循环”通常不是 while 循环而是QTimer。起点是启动游戏时创建一个 50ms 触发一次的定时器每次触发调用场景类的onTick()void LevelScene::startGame() { m_elapsedMs 0; m_timer new QTimer(this); m_timer-setInterval(50); m_timer-setTimerType(Qt::PreciseTimer); connect(m_timer, QTimer::timeout, this, LevelScene::onTick); m_timer-start(); setGameState(GameRunning); } void LevelScene::onTick() { m_elapsedMs 50; trySpawnZombie(); for (Plant* plant : m_plants) plant-onTick(m_elapsedMs); for (Zombie* zombie : m_zombies) zombie-onTick(m_elapsedMs); for (Pea* pea : m_peas) pea-onTick(m_elapsedMs); resolveFights(); cleanDeadObjects(); }执行顺序是有讲究的先让植物和僵尸更新状态再让豌豆移动最后统一结算伤害并清理死亡对象。如果把resolveFights()放在移动之前会造成画面已经交错、但判定还在上一帧位置的错位感。m_elapsedMs是全局逻辑时钟所有冷却和波次表都依赖它而不是依赖QTime::currentTime()这样可以保证暂停和继续时逻辑不会乱。Qt::PreciseTimer这个参数容易被忽视。默认定时器在系统省电模式下可能被合并导致游戏突然卡一下然后跳帧。对于 50ms 的逻辑帧合并一次可能就是一次明显的僵尸瞬移。定时器间隔对课程设计手感影响很大可以按下面的表选interval表现适用情况20ms逻辑流畅动画细腻CPU 占用高机器性能富余的演示机50ms折中适合绝大多数课设最常用的默认值100ms僵尸移动一顿一顿豌豆偏慢需要明显放慢节奏时临时调如果是在自己电脑上复现源码编译环境建议直接用 Qt 自带的 MinGW 套件省去 vs 版本匹配的折腾。MSVC 版本不匹配时Qt 经常报microsoft visual c 14.0 or greater is required之类的错误这属于环境问题和源码逻辑无关。4.2 豌豆冷却不是“每秒减 1”而是比较绝对时间戳射击冷却最常见的错误实现是每个 tick 里让一个计数器减 1减到 0 就发射子弹。这个做法在窗口被拖动、定时器跳帧时会导致连续补发看起来就像豌豆射手抽风。常见的落地做法是让Peashooter保存上次射击的绝对时间戳每次逻辑帧比较差值void Peashooter::onTick(qint64 nowMs) { if (type() ! Peashooter) return; if (m_lastShotMs 0) m_lastShotMs nowMs; if (nowMs - m_lastShotMs m_fireIntervalMs) { m_lastShotMs nowMs; emit requestShoot(row(), scenePos().x() boundingRect().width()); } }关键在于初始情况下m_lastShotMs为 0第一次 tick 要把它设为当前时间否则第一发子弹会在启动瞬间立刻发射。信号requestShoot把行号和作用点的 X 坐标传给场景场景负责生成豌豆图元并加入m_peas列表。把子弹生成改成信号而不是在 Plant 内部直接创建 Pea是为了让 Plant 不依赖具体场景对象替换植物类型时不用改信号逻辑。豌豆碰撞僵尸后要移除自身这时的对象生命周期管理比较麻烦。不建议在collidingItems()的循环里直接deleteLater()再继续遍历因为 Qt 的碰撞列表不会马上更新会出现一次命中让豌豆穿过僵尸的视觉残留。更稳妥的顺序是先标记死亡加入m_peasToRemove列表等resolveFights()结束后统一清理。4.3 把图片和音效收进 qrc让源码包直接可跑课程设计源码的压缩包一旦换目录最常见的崩溃就是图片加载不到。QImage(assets/sun.png)这种相对路径写法在 Windows 上依赖当前工作目录双击运行和 Qt 调试器运行时的工作目录不一样结果就会一个能跑一个黑屏。把资源收进 qrc 文件是最稳的做法qt 绘图相关的代码统一用资源路径RCC version1.0 qresource prefix/game file aliassunflower.pngassets/sunflower.png/file file aliaspeashooter.pngassets/peashooter.png/file file aliaszombie.pngassets/zombie.png/file file aliasdie.wavassets/die.wav/file file aliasshot.wavassets/shot.wav/file /qresource /RCC在 .pro 文件里加上RESOURCES res.qrc后代码里用:/game/peashooter.png就能加载图片。音效部分有一个高频坑QSoundEffect只支持 WAV 格式的未压缩音频把 MP3 塞进 qrc 后播放会静音。课程设计里如果需要“僵尸死亡”音效先用格式工厂或 ffmpeg 转成 16bit 44.1kHz 的 WAV 再导入比在代码里尝试各种播放器类都省事。另外压缩包如果传给老师后解压路径里带中文qrc 资源不受影响但外部相对路径的音频文件会失效这也是“把资源全部收进 qrc”作为通用建议的原因。5. 答辩前把这三块调稳帧率面板、出怪节奏和可回放日志5.1 右上角显示真实帧率用数字解释逻辑帧答辩时最怕被问“你知道游戏跑多少帧吗”。与其现场猜不如在界面上直接放一个QLabel每 500ms 刷新一次。实现方式是在onTick()末尾调用一个统计函数void LevelScene::updateFpsLabel() { static int frames 0; static qint64 lastMs 0; frames; qint64 nowMs m_elapsedMs; if (nowMs - lastMs 500) { double fps frames * 1000.0 / (nowMs - lastMs); m_fpsLabel-setText( QString(FPS: %1 / LogicTick: %2ms) .arg(fps, 0, f, 1) .arg(m_timer-interval())); frames 0; lastMs nowMs; } }这里显示的 FPS 是刷新次数不是逻辑帧数。场景一有变化QGraphicsView就会触发重绘所以 FPS 通常会高于 20而逻辑帧由QTimer决定永远是 50ms 一条。答辩时强调“游戏手感由逻辑帧决定、画面由 Qt 自动重绘”这句话比单纯展示运行效果更有说服力。5.2 出怪节奏别用纯随机用波次表控制随机出怪在演示时很容易出现两分钟不出怪、观众干等的尴尬局面。常见做法是把出怪规则写成波次表用m_elapsedMs做时间轴struct WaveRule { int startMs; int zombieCount; int countPerSpawn; }; std::vectorWaveRule waves { { 5000, 3, 1 }, { 20000, 6, 2 }, { 40000, 10, 3 }, };startMs是波次启动时间zombieCount是这一波总僵尸数countPerSpawn是每次刷出几只。波次表的好处是可以在演示前明确说出“20 秒后会有第一波密集僵尸”也能在调试时把某波次的zombieCount调小来测试后期植物强度。5.3 用 qDebug 打事件日志回放最后几秒当“僵尸走到某行却不咬植物”这种问题出现时只靠看屏幕很难判断是碰撞没触发还是行号判断错了。在关键事件点打日志是课设排错里成本最低的手段qDebug() tick m_elapsedMs spawn zombie row row pos zombie-scenePos();日志里带上m_elapsedMs和scenePos()复盘时能直接从时间戳对出“僵尸已经到达第 3 行第 7 格但 isEating 一直是 false”。把这三个验证点收进源码包后课设演示基本不会在“为什么僵尸不动”上翻车。本文还有配套的精品资源点击获取