
简介这是一份基于C实现的保卫萝卜塔防游戏课程设计项目以经典塔防玩法为原型覆盖从游戏界面、防御塔建造升级到怪物波次、金币生命等核心机制适合正在学习C游戏编程、需要完成大作业或课程设计的本科学生参考。压缩包共294个文件约23.48MB主要包含cpp头文件源码、png美术资源、qrc资源管理配置、pro工程文件以及docx说明文档另有mp3背景音乐与可直接运行的exe程序可对照源码与文档理解整体架构。游戏设置了三种关卡、四种防御塔、五种怪物玩家需合理分配初始1000金币建造升级防御塔在6条生命内消灭全部怪物完成通关。目前已有483人学习下载对想快速上手塔防游戏框架、熟悉Qt项目组织方式的开发者来说这份完整项目源码与资源具备不错的拆解与复用价值。1. 用 C 还原保卫萝卜这份课设的价值不只是能跑第一次在 Qt Creator 里打开这个 C 塔防游戏课程设计时我特意先翻了TowerDefense.pro.user.cfce6d0而不是直接点运行。这份源码的核心目标是用 Qt 把保卫萝卜的玩法还原成可交互场景。主窗口装载 BGM三个关卡各自定义出怪口和行进路径玩家只能在预设的 TowerPosition 上建设四种防御塔用初始 1000 金币应付五种怪物的多波进攻6 条命耗尽则游戏结束。适合三类人课程设计想找可二次开发底座的、入门 Qt 后想用游戏验证图形框架的、以及想研究 QGraphicsScene 事件模型在完整项目里如何落地的。这篇拆解只关心一件事怎么让它在你的机器上可编译、可复现、可扩展。2. 工程骨架pro.user、qrc_resource.cpp 与 QGraphics 场景分层打开解压目录后能看到三类文件pro.user 的副本、qrc_resource.cpp 和 mainwindow.cpp。许多人把这三者都当成源码一股脑提交但它们的角色完全不同。pro.user.cfce6d0 是 Qt Creator 为当前电脑生成的构建配置记录了编译器套件、构建目录等本地信息换了电脑通常会重新生成qrc_resource.cpp 是执行 qmake 后由 rcc 工具根据资源文件自动生成的编译单元mainwindow.cpp 才是真正要改的主窗口逻辑。理解这三者的分工能帮你少走一个最常见的弯路直接在 qrc_resource.cpp 里手改资源内容这是无效操作。2.1 用 pro 文件控制项目形态TowerDefense.pro 是 qmake 的入口。课程设计里最容易拿分的地方往往就是把SOURCES、HEADERS、RESOURCES列清楚让老师拿到后在 Qt Creator 里一次编译通过。一个典型的塔防工程配置如下。QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c11 TARGET TowerDefense TEMPLATE app SOURCES main.cpp \ mainwindow.cpp \ tower.cpp \ enemy.cpp \ projectile.cpp HEADERS mainwindow.h \ tower.h \ enemy.h \ projectile.h RESOURCES resource.qrcTEMPLATE app指定生成可执行程序而不是库CONFIG c11允许使用nullptr、范围 for 等现代语法RESOURCES把图片、音频统一编入二进制。这里值得注意如果你的 pro 文件里写了greaterThan(QT_MAJOR_VERSION, 4)而在 Qt 6 下编译这段兼容写法仍然有效但 Qt 6 的模块划分有变化建议直接按所用主版本核对模块名。配置项作用常见误用CONFIG c11启用 C11 语法写成CONFIG C11导致无效果TEMPLATE app生成应用不写默认为app但显式写出更清晰RESOURCES ...编入 qrc 资源资源路径写错后图片、BGM 全部加载失败表里的最后一项是这类项目最高频的故障点后面第 2.2 节会专门演示路径写法。2.2 音频与图片qrc 资源路径是统一入口qrc_resource.cpp 的名字来源于 resource.qrc 的 rcc 预处理。你可以把它理解成 Qt 在编译时把所有资源编成一组内存字节流程序运行时统一通过qrc:/前缀访问。下面是一份支持 BGM 和塔图片的 qrc 片段。RCC qresource prefix/audio file aliasbgm.mp3assets/bgm.mp3/file /qresource qresource prefix/images file aliastower.pngassets/tower.png/file /qresource /RCC在 mainwindow.cpp 里播放 BGM 的常见做法是用QSoundEffect它适合短音频循环播放比QMediaPlayer更轻量。QSoundEffect* bgm new QSoundEffect(this); bgm-setSource(QUrl(qrc:/audio/bgm.mp3)); bgm-setLoopCount(QSoundEffect::Infinite); bgm-setVolume(0.5f); bgm-play();setSource使用的qrc:/audio/bgm.mp3对应 qrc 文件中的prefix alias不是磁盘相对路径setLoopCount(QSoundEffect::Infinite)让 BGM 循环setVolume参数范围是 0.0 到 1.0。如果改成setSource(QUrl(assets/bgm.mp3))运行时一定会报“找不到文件”因为此时 Qt 不会去解析 qrc 前缀。2.3 场景负责算视图负责画塔防里的坐标统一在QGraphicsScene中维护QGraphicsView只当作观察窗口。这种分工让“怪物出现在画面外”这类问题定位起来非常容易先看 scene 坐标是否越界再看 view 是否发生了滚动。scene new QGraphicsScene(this); scene-setSceneRect(0, 0, 960, 640); view new QGraphicsView(scene, this); view-setFixedSize(960, 640); view-setRenderHint(QPainter::SmoothPixmapTransform); view-centerOn(480, 320);setSceneRect(0, 0, 960, 640)定义逻辑世界的边界道路和 TowerPosition 都应该落在这个范围内SmoothPixmapTransform让缩放后的塔图片边缘更柔和代价是轻微的绘制开销centerOn(480, 320)让视图初始居中。如果关卡地图比窗口大后续切换关卡时只需改scene-setSceneRect不必动任何怪物坐标。3. 塔与怪物的类设计多态、路径推进与距离判定把玩法翻译成类第一反应常常是“写一个 Tower 类里面塞满攻击逻辑”。但四类塔的实现差异不小有的单发、有的溅射、有的减速把它们全塞进一个类会让 switch-case 越来越多。更符合 C 习惯的做法是定义抽象基类让每个具体塔只负责自己的开火策略。3.1 基类 Tower 与四个派生塔class Tower : public QObject, public QGraphicsPixmapItem { Q_OBJECT public: Tower(int price, int range, int damage, QGraphicsItem* parent nullptr); virtual ~Tower() default; virtual void fire(QListEnemy* enemies) 0; int price() const { return m_price; } int range() const { return m_range; } int damage() const { return m_damage; } protected: int m_price; int m_range; int m_damage; };派生类只需要重写fire。例如单发箭塔把最近敌人作为目标溅射炮塔在命中点周围找一圈敌人减速塔则在子弹命中后给敌人挂一个减速标记。这样新增一种塔不需要更改主窗口的框架代码只需要新增一个派生类和对应的图片资源后续调平衡也不用反复翻主逻辑。塔的参数按“价格越高威力越大”的原则设计一份常见的默认参数如下。这些数值不是官方配置只是便于课程设计直接套用的示例。塔类型价格攻击半径单发伤害特殊效果箭塔10016020单体炮塔20014045溅射减速塔1501708减速 30%火焰塔25011035持续灼烧3.2 Enemy 的状态推进沿固定路径走不做每帧寻路塔防地图的路线是固定的怪物只需要按顺序经过一系列路径点。为每只怪物每帧做 A* 搜索毫无必要正确的做法是把路径点保存在 Enemy 内部每帧只朝下一个点移动。class Enemy : public QObject, public QGraphicsPixmapItem { Q_OBJECT public: Enemy(const QVectorQPointF path, int hp, int speed, int reward); void advance(int phase) override; signals: void reachedGoal(Enemy* enemy); private: QVectorQPointF m_path; int m_pathIndex 0; int m_hp; int m_speed; int m_reward; };推进逻辑在advance(int phase)中实现。QGraphicsScene每一帧会调用所有 Item 的advance(0)和advance(1)两次前者用于更新状态后者用于真正的移动。利用这个阶段区分可以避免“一边移动一边被同帧子弹命中”的时序混乱。void Enemy::advance(int phase) { if (phase 0) return; if (m_pathIndex m_path.size()) { emit reachedGoal(this); return; } QPointF target m_path.at(m_pathIndex); QPointF delta target - pos(); qreal length QLineF(pos(), target).length(); if (length m_speed) { setPos(target); m_pathIndex; } else { setPos(pos() delta / length * m_speed); } }delta / length * m_speed是一个归一化向量乘法保证怪物每帧移动距离恒定为m_speed不会因为路径线段长度不同而出现速度漂移。length m_speed判定的是“本帧能否走完剩余距离”能走完就直接落点并进入下一个路径点否则只走一小步。如果你把这里的写成怪物会在最后一个路径点前反复震荡这就是塔防里怪物卡在拐角的经典 bug。3.3 射程判断用平方距离代替矩形碰撞塔的射程是一个圆形区域很多刚接触 Qt 的人会把collidesWithItem或boundingRect().intersects当成判断依据但这两种方式都依赖 Item 的包围盒和圆形射程并不一致。更干净的方案是直接用坐标算距离。bool isInRange(const QPointF towerPos, const QPointF enemyPos, qreal range) { qreal dx enemyPos.x() - towerPos.x(); qreal dy enemyPos.y() - towerPos.y(); return (dx * dx dy * dy) range * range; }使用平方距离而不是sqrt是因为每帧要对塔和所有敌人做多组判断开平方运算在数量上来后会成为热点。课程设计阶段看不出差别但你在简历上写“每帧数百次距离判断”时这就是一个值得提的优化点。同样地当你需要对当前攻击半径内的敌人按血量排序以确定优先目标时不要手写冒泡排序std::sort配合 Lambda 是更符合现代 C 审美的做法。4. 游戏主循环与弹道更新QTimer、对象生命周期与胜负结算塔防不是静态界面它需要一个持续运转的循环来推进怪物、刷新塔攻击、检查生命和金币。最常见的做法是用QTimer定时触发帧回调而不是在main()里写while (1)。后者会直接阻塞 Qt 的事件循环导致点击放置塔完全无响应。4.1 用 QTimer 驱动 60 FPS 的游戏帧QTimer* gameTimer new QTimer(this); gameTimer-setTimerType(Qt::PreciseTimer); connect(gameTimer, QTimer::timeout, this, MainWindow::onFrame); gameTimer-start(16);start(16)表示约 60 FPSQt::PreciseTimer尽量减少系统定时器的精度损失如果你看到怪物移动一卡一卡先把 16 改成 10 再观察不要急着优化绘图。每一帧的处理顺序应该固定。我看到不少崩溃都出在顺序颠倒上比如先把敌人删掉塔再去找目标于是拿到一个悬空指针。void MainWindow::onFrame() { // 1. 移动所有怪物 for (Enemy* e : qAsConst(m_enemies)) { e-advance(1); } // 2. 让所有塔开火 for (Tower* t : qAsConst(m_towers)) { t-updateCooldown(m_enemies); } // 3. 清理死亡敌人并发放金币 cleanDeadEnemies(); // 4. 刷新 HUD updateStatusBar(); }先移动再攻击再回收是因为“回收”会触发 delete 等内存操作放在最后能保证本轮循环里所有对象都还存活。cleanDeadEnemies里不要直接delete enemy后再用循环继续访问容器正确做法是先m_enemies.removeAll(enemy)再调用enemy-deleteLater()。一个容易被忽略的细节如果你在这里引入多线程去计算弹道就会面临 UI 线程和计算线程同时访问m_enemies的竞争条件。这个规模的塔防根本不需要多线程单线程 16ms 一帧绰绰有余。课程设计里刻意使用std::thread往往不是加分项反而是崩溃的来源。顺序操作留意点1Enemy::advance(1)移动怪物保证移动后的坐标对投射物可见2Tower::updateCooldown塔开火塔要看到最新敌人坐标3cleanDeadEnemies清理尸体避免同帧再访问失效对象4updateStatusBar刷新 HUD金币和命数在本帧末尾一致4.2 弹道投射物跟踪目标还是直线飞向预定落点塔发射的子弹有两种常见实现方式一种是生成后沿直线飞向固定落点另一种是每一帧重新计算目标当前位置完成跟踪。跟踪型弹道更适合塔防因为怪物在移动固定落点容易打空。投射物类可以这样写。class Projectile : public QObject, public QGraphicsPixmapItem { Q_OBJECT public: Projectile(QPointF start, Enemy* target, int damage, int speed); public slots: void updatePosition(); private: Enemy* m_target; int m_damage; int m_speed; };updatePosition里最安全的目标判断是先检查目标是否还在场景里以及血量是否大于零。void Projectile::updatePosition() { if (!m_target || m_target-hp() 0) { deleteLater(); return; } QPointF diff m_target-pos() - pos(); qreal dist QLineF(pos(), m_target-pos()).length(); if (dist m_speed) { m_target-takeDamage(m_damage); deleteLater(); } else { setPos(pos() diff / dist * m_speed); } }这里的关键不是向量运算而是deleteLater()。投射物在updatePosition里销毁自己最安全的时机是 Qt 事件循环回到空闲状态时而不是立刻delete this。同理Enemy 被击杀死后其内部如果有指向它的投射物这些投射物会在下一次updatePosition中因为m_target-hp() 0而被安全回收。用这种“哨兵”式的检查代替强行断开指针能大幅减少野指针崩溃。提示当怪物走到终点时即使这一帧塔的子弹已经锁定它目标对象仍然有效deleteLater会在事件循环空闲时才真正销毁所以Projectile::updatePosition中的空指针检查仍然安全。4.3 扣血与胜负判定先结算生命再判断游戏结束怪物走到终点时玩家生命值减少。这里同样要设定严密的顺序扣血、更新 UI、再判断胜负。如果把“更新 UI”放在“判断胜负”后面游戏结束时 HUD 上可能来不及显示剩余生命。void MainWindow::onEnemyReachedGoal(Enemy* enemy) { m_lives--; emit livesChanged(m_lives); enemy-deleteLater(); if (m_lives 0) { gameOver(false); } }发出livesChanged后状态栏立即刷新gameOver(false)负责停止计时器并显示失败界面。注意enemy-deleteLater()在发出信号之后调用这样任何正在处理reachedGoal信号的槽都还能安全读取 enemy 的信息。如果你先用delete enemy再发信号很可能在槽函数里访问到未定义内存。5. 关卡配置波次表、路径点与 TowerPosition 占位三个关卡的区别不能靠复制粘贴 mainwindow.cpp 里的 if-else 实现否则调一次难度就要改一次代码。把波次、路径、出生点抽象成数据是这里最值得花时间的设计。5.1 波次表用结构体数组描述怪物出没节奏波次数据适合用两个简单结构体来表达。struct SpawnItem { int monsterType 0; // 0 到 4代表五种怪物 int intervalMs 1000; // 同波内相邻怪物的出生间隔 int count 3; // 该条目总共出多少只 }; struct WaveConfig { int waveId; QVectorSpawnItem spawnList; }; QVectorWaveConfig levelWaves { {1, {{0, 1500, 5}, {1, 3000, 3}}}, {2, {{0, 1000, 8}, {1, 2000, 4}, {2, 4000, 2}}}, };这里的QVectorWaveConfig就是关卡的数据驱动核心。主循环维护一个m_spawnClock和一个m_currentWaveIndex当计时达到intervalMs时生成一只怪物count 耗尽后切换到下一组 SpawnItem。这种写法把“第几波出哪种怪”从代码中剥离出来调整难度只需要改levelWaves这个数组。即使你把五种怪物的血量、速度和金币奖励分别放进 Enemy 的工厂函数里波次表也完全不关心这些数值两者只用monsterType进行关联。字段类型含义monsterTypeint0 到 4对应五种怪物intervalMsint同波内相邻怪物的生成间隔countint当前条目需要出怪的数量5.2 路径数据出生点不同但路径都是 QPointF 数组不同关卡的实际差异主要体现在路径上。既然地图固定路径点可以直接用QVectorQPointF硬编码。static const QVectorQPointF kPathLevel1 { QPointF(0, 360), QPointF(240, 360), QPointF(240, 160), QPointF(480, 160), QPointF(480, 480), QPointF(720, 480), QPointF(720, 280), QPointF(960, 280) };路径点建议取道路中心线相邻两点不要相隔太近否则怪物会在拐角处出现明显的抖动。每只 Enemy 构造时都持有这份路径所以出生点不同、路径不同只是传入的QVector不同Enemy 类无需任何改动。如果你在课程设计里想展示更高级的写法可以把这些路径存成文本文件或 JSON启动时解析到QVectorQPointF。这不影响运行时逻辑只增加了部署文件的复杂度是否值得取决于老师有没有对“配置文件驱动”提要求。5.3 TowerPosition可放置地点用场景 Item 表达塔不能随便摆在路上所以地图上需要预设的格子。这个格子用QGraphicsItem表达再合适不过因为 Qt 天然支持点击事件。class TowerPosition : public QObject, public QGraphicsItem { Q_OBJECT public: TowerPosition(const QPointF pos, int size 60); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; bool isOccupied() const { return m_occupied; } void setOccupied(bool occupied) { m_occupied occupied; update(); } signals: void posClicked(TowerPosition* position); protected: void mousePressEvent(QGraphicsSceneMouseEvent* event) override; private: bool m_occupied false; int m_size; };在mousePressEvent里发出posClicked信号由主窗口决定是否弹出建塔菜单。把“点击位置”和“扣金币、创建塔”解耦主要是因为后者依赖当前玩家金币与选中塔型放在槽函数里处理更灵活。这里也有一个容易踩的坑塔放置后要立刻setOccupied(true)否则鼠标快速点击两次会在同一个位置扣两次金币建两座塔。用一句话描述这个设计的价值TowerPosition 只是占位符真正的游戏对象 Tower 由主窗口在槽函数中动态创建这样升级塔时也只需要修改 Tower 内部数据不需要动这个格子。6. 启动顺序与资源加载排错先编译、再验证 qrc 路径如果你在别的电脑上打开这份工程第一件事不是直接点绿色运行按钮而是遵循一个固定的启动顺序能省下大量排错时间。6.1 从命令行验证编译环境先把构建目录独立出来用 qmake 生成 makefile。mkdir build cd build qmake ../TowerDefense.pro make -j4make -j4里的 4 表示并行编译任务数CPU 核心多的机器可以改成-j8。如果这一步在qmake阶段就报错通常说明 Qt 模块缺失如果在make阶段报错优先看mainwindow.cpp里是否包含了#include QSoundEffect或者#include QGraphicsScene等必要头文件。命令行能通过后再用 Qt Creator 打开能避免 IDE 缓存干扰排错。6.2 资源不显示的定位顺序塔防运行后图片全黑、BGM 无声90% 是资源路径问题。按下面的顺序检查确认构建目录中有没有生成 qrc_resource.cpp如果没有说明RESOURCES resource.qrc没有被 pro 文件正确加载。在代码中加载 QPixmap 后判断isNull()迅速定位是路径错误还是文件本身损坏。确认 alias 与代码中的路径完全一致大小写也要检查。QPixmap pixmap(:/images/tower.png); if (pixmap.isNull()) { qDebug() tower.png load failed, check qrc path; return; } setPixmap(pixmap);qDebug()会打印到 Qt Creator 的“应用程序输出”窗口是这类问题最直接的定位手段。如果你在 Linux 下编译路径大小写必须与 alias 完全一致Windows 下不区分大小写但为了跨平台还是一律采用小写命名更稳妥。BGM 不响可以先看声音文件是否存在再确认setLoopCount(QSoundEffect::Infinite)是否在play()之前调用。资源稳定加载后再回到第 5 章的波次表去调数值。这个顺序保证你每次遇到的错误都是同一类不会因为资源问题误判成塔的攻击逻辑 bug。最后再补一个实用小技巧当同一份工程需要在多台电脑上评分时直接把构建目录和*.pro.user.*文件清理掉只保留 pro、qrc、cpp、h 和 assets 目录。评分老师用当前版本的 Qt 重建时qmake 会重新生成与当前环境匹配的构建配置。这样交上去的课程设计比带一堆本地缓存文件更干净也更容易一次编译通过。本文还有配套的精品资源点击获取