简介:这是一份基于C++实现的保卫萝卜塔防游戏课程设计大作业,面向C++初学者、游戏开发爱好者及需要完成类似课设的高校学生。项目以经典塔防玩法为蓝本,完整实现了游戏主菜单、背景音乐、三个关卡、多种怪物出生逻辑以及六条生命值机制,玩家通过放置四种不同价格与威力的防御塔、升级塔防来抵御五类怪物,整体逻辑清晰,适合作为理解面向对象编程、事件循环与游戏状态管理的实战案例。资源共包含294个文件,压缩包约23.48MB,其中cpp源码与h头文件构成核心逻辑,png图片与mp3音乐提供美术与音效素材,qrc、pro及ui文件用于Qt工程配置与界面布局,docx文档可用于报告参考,另有exe可执行文件便于快速体验效果。目前已有483人学习浏览,可直接运行或二次开发,是课程设计与自主练手的实用素材。
1. 把“保卫萝卜”用 C++ 还原:一份跑得通的塔防课程设计源码
塔防游戏几乎占 C++ 小游戏课程设计的半壁江山——玩法直白、画面友好,可真动手就会发现碰撞检测、资源加载、波次调度一碰一个坑。这份以“保卫萝卜”为模板的 C++ 塔防游戏课程设计(项目编号 100013158)不是拿引擎套模板,而是用 Qt Widgets 把玩法逻辑真正写了一遍:开始界面带 BGM,三个关卡对应不同出怪路径,五种怪兽加四种防御塔,初始 1000 金币,打死怪物给钱,塔能升级,六条命用光判负,清完波次过关。拆完代码后我确认了堆叠在 mainwindow.cpp 和 qrc 资源里的不是贴图演示,而是一整套可跟踪的状态机。这份资源适合两类人:想应付课程设计又不打算从零写的,以及想搞懂 C++ 游戏对象怎么组织、事件循环怎么驱动玩法的人。
2. 从 mainwindow 到 qrc:先摸清这个塔防工程的骨架与素材挂载
2.1 主窗口类里藏着一整个游戏状态机
拆这类游戏工程,我习惯先不看画面,只找“当前是哪个场景”这个变量。这次在 mainwindow.cpp 里能明显看到主窗口承担了三种界面:开始页、选关页、战斗页。它没有用复杂的界面框架,而是靠切换控件可见性加状态枚举完成页面流转,好处是小工程里信号槽直连,几行代码就能从开始页跳到战斗页。
// mainwindow.h 关键成员(节选) class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); private: // 三种界面:开始页、选关页、战斗页之间切换 enum class SceneType { Start, LevelSelect, Battle }; SceneType m_currentScene = SceneType::Start; int m_levelIndex = 1; // 当前关卡 1~3 int m_gold = 1000; // 初始金币,打死怪再加 int m_baseHealth = 6; // 6 条命,归零则游戏结束 QTimer *m_gameTimer = nullptr; // 驱动刷怪、塔攻击的主循环 QVector<Tower*> m_towers; // 已建造的防御塔 };这里有两个点值得细看。一是 m_currentScene,它决定了点击鼠标时到底该响应“开始游戏”还是“在某个格子上建塔”,所有点击事件回调第一步都是 switch 这个枚举,后期你是加“暂停菜单”还是加“结算界面”,都绕不开它。二是 m_gameTimer,塔防里所有带时间轴的动作——刷怪、移动、塔的射击——都由这一个定时器轮询驱动,interval 一般设置在 30~50ms 之间。
如果你拿到工程后想先验证状态机,最直接的办法是给场景切换槽函数打断点,例如在 startGame() 这个槽里观察 m_currentScene 从 LevelSelect 变成 Battle 的过程。很多新手看到页面能跳转就以为理解了工程,实际上页面跳转只是状态值变化,真正的游戏逻辑是另一套在战斗场景内持续运行的系统。
2.2 qrc 资源文件:BGM 与贴图不是直接用路径找的
工程的资源文件是 qrc_resource.cpp,这名字一看就是从 .qrc 资源描述文件用 Qt 的 rcc 工具生成出来的。qrc 的核心作用是把图片、音频打进二进制资源,运行时不需要再担心相对路径失效——这一点在课程设计演示时尤其救命,因为代码拷到别的机器,绝对路径通常就断了。
<!-- resource.qrc 结构示例,路径以实际工程为准 --> <RCC> <qresource prefix="/"> <file>audio/bgm_start.mp3</file> <file>images/monster_01.png</file> <file>images/monster_02.png</file> <file>images/tower_arrow.png</file> <file>images/tower_cannon.png</file> </qresource> </RCC>注意 prefix 这一段。写成prefix="/"时,代码里取资源是:/images/tower_arrow.png;如果把 prefix 改成/assets,那就要写:/assets/images/tower_arrow.png。工程里 BGM 播放一般是这样:
// 用 QMediaPlayer 播开始界面 BGM QMediaPlayer *player = new QMediaPlayer(this); player->setMedia(QUrl("qrc:/audio/bgm_start.mp3")); player->setVolume(60); player->play();我给课程设计做演示时强调过:qrc 里文件路径大小写不对,经常是运行时黑屏但编译一点错都没有,排查优先级要排在逻辑 bug 之前。另外 qrc_resource.cpp 这一层是 rcc 生成物,平时改完 .qrc 要重新执行 qmake 让 Qt Creator 重新编译它,只按 Ctrl+S 保存但忘记重新构建,资源不更新也是常见翻车点。
2.3 TowerPosition:关卡地图与可建塔位的映射方式
关卡地图在这个项目里不是美术画出来的背景图片,而是一层可碰撞的逻辑网格。点击地图上某个位置时,代码先判断这个点击点是否命中 TowerPosition——也就是地图里允许建塔的格子列表。
// 关卡地图读取:用数字标记格子类型,0=不可建,1=可建塔 // 这里以 8 列 x 5 行的示意数组为例,实际工程按关卡放大 int map[5][8] = { {0,0,1,0,1,0,0,1}, {0,1,0,1,0,1,1,0}, {1,0,1,0,0,1,0,0}, {0,1,0,1,1,0,1,0}, {1,0,0,1,0,1,0,1} }; // 根据 map 建立可以放塔的位置列表 QVector<QPoint> m_buildPositions; for (int row = 0; row < 5; ++row) { for (int col = 0; col < 8; ++col) { if (map[row][col] == 1) { int worldX = col * CELL_WIDTH + CELL_WIDTH / 2; int worldY = row * CELL_HEIGHT + CELL_HEIGHT / 2; m_buildPositions.append(QPoint(worldX, worldY)); } } }CELL_WIDTH、CELL_HEIGHT 是单元格像素宽高,一般取 64 或 80。这个数组设计直接影响后续所有逻辑:怪兽走的路要避让这些塔位,否则会出现塔建在路上这种尴尬情况。三关的地图差异,本质是 map 数组不同、出生点不同、终点位置不同。做完这份工程想新增第四关,核心工作就是再画一张这样的二维数组并配好路径点。
3. 把出怪逻辑跑通:波次队列、路点移动与命数扣减
3.1 两种路径实现方式,为什么路点折线更适合课程设计
塔防的移动路径有两种常见实现:一种是按格子 A* 寻路,灵活但实现重;另一种是预先定义一串路点,怪物沿路点依次移动。这份项目属于后者,原因是“保卫萝卜”里怪物的行走路线是固定的,不需要动态绕障碍。路点方式代码量少、肉眼可验证,特别适合课程设计。路点数据结构一般长这样:
// 路径点,例如 {(80, 80), (400, 80), (400, 400), (720, 400)} QVector<QPoint> path = { QPoint(80, 80), QPoint(400, 80), QPoint(400, 400), QPoint(720, 400) }; // 怪物沿路点移动,每帧步进 void Monster::moveStep(int speed, const QVector<QPoint>& path) { if (m_waypointIndex >= path.size()) { return; // 已到达终点,由外部逻辑处理扣命 } QPoint target = path[m_waypointIndex]; QPoint delta = target - pos(); float distance = std::sqrt(delta.x()*delta.x() + delta.y()*delta.y()); if (distance <= speed) { pos() = target; m_waypointIndex++; // 走到点了,切下一个路点 } else { float ratio = speed / distance; pos() += delta * ratio; } }这里有三个参数决定难度:speed 是每帧移动像素数,值越大怪走得越快;m_waypointIndex 是怪物自身的状态,决定了它正在走第几段路;std::sqrt 那段是向量归一化的变体——先算剩余距离,小于单帧步长就直接落到路点,否则按比例朝目标插值。我见过有人把这逻辑写反,导致怪物到点前在两点之间来回震动,原因就是 distance 算出来但没处理distance <= speed的边界,浮点数精度也会在这里捣乱,建议判断时留 0.5 像素的容差。
3.2 用 QTimer 和波次队列控制出怪节奏
战斗页里的 QTimer 每隔几十毫秒触发一次 tick,tick 里做三件事:从波次队列取要生成的怪、给已有怪物调 moveStep、通知防御塔索敌开火。波次数据不写死在一个数组里,而是拆成 wave 对象队列。
struct Wave { int monsterType; // 怪物类型,1~5 int count; // 这一波该类型出多少只 int intervalMs; // 两只怪之间的出怪间隔(毫秒) int delayBeforeMs; // 距上一波结束后的等待时间 }; class SpawnManager { public: void loadWaves(int levelIndex); // 按关卡载入不同波次表 bool isWaveFinished() const; // 判断波次是否全部出完 void update(int elapsedMs); // 每帧检查是否到点出怪 private: QQueue<Wave> m_waves; int m_spawnTimer = 0; };常见做法是 mainwindow 的 tick 里调用spawnManager->update(elapsedMs),SpawnManager 内部维护 m_spawnTimer 累加,到点后从 m_waves 队首弹出并生成对应 Monster。这个结构的价值在于换关卡难度只需改 loadWaves 里 push 进去的 Wave 数据,不需要动业务代码。做课程设计答辩时,把这一层关系讲清楚比贴一百行画界面代码有说服力得多。
生成的怪物要避免纯手写位置,应该从出生点列表里取,三关的出生点不同,这一点通常是在关卡配置里放的 QPoint 数组。想让难度递进得更有节目效果,可以在 loadWaves 里用 C++ 随机数给 intervalMs 加一点抖动,比如intervalMs = baseInterval * (0.9 + rand() % 20 * 0.01),前提是种子初始化别忘qsrand(QTime::currentTime().msec())(Qt 5 写法)或直接用std::random_device。
3.3 “6 条命”扣减的最佳判定位置
这个项目有个很关键的设定:玩家总共有六条命,怪物走到终点即扣一条,六条用光游戏结束。扣命的位置有讲究,放在怪物移动逻辑里是最省事的,但容易重复扣——一只怪物如果没做标志位,在“到达终点”前每帧都等于终点,就会连续扣好几条。
正确的做法是给 Monster 加一个m_reachedEnd标志。如下面的逻辑:
// 每一帧都调用,但只让第一次到达终点的瞬间生效 void GameLevel::onMonsterReached(Monster* monster) { if (monster->hasReachedEnd()) { return; // 已经到达过终点,忽略重复调用 } monster->setReachedEnd(true); m_baseHealth--; if (m_baseHealth <= 0) { endGame(false); // 游戏失败:弹结算面板或直接返回开始页 } monster->deleteLater(); }同样的“防重复”思路适用于金币。打死怪物给钱时也要先检查怪物是否已标记死亡,否则伤害帧没清掉,怪物“死而复生”反复打反复给钱,经济系统就直接崩盘。这条命数的扣减位置是整个游戏的主干判定点,只要它写对了,通关、失败、重开的边界就都清楚了。
4. 四种防御塔的攻击结算:价格、伤害与升级曲线的联动
4.1 四套塔的参数表:价格与威力从哪来
塔防最基本的平衡就是“贵塔打得疼、便宜塔打得勤”,这份工程里四座塔分别承担了单体高伤、攻速压制、范围伤害和折中的角色,参数直接写成一张配置表来维护。
| 塔类型 | 建造价格 | 基础伤害 | 攻击间隔(ms) | 射程(px) | 特点 |
|---|---|---|---|---|---|
| 箭塔 | 100 | 10 | 600 | 200 | 廉价单点,前期主力 |
| 炮塔 | 150 | 25 | 900 | 180 | 高单发伤害,攻速慢 |
| 魔法塔 | 120 | 8 | 300 | 220 | 极快速低伤 |
| 范围塔 | 200 | 6 | 1000 | 150 | 对溅射范围额外结算 |
这张表在工程里一般是数值常量数组,不是散落在各个塔类的构造函数里。之所以强调,是因为如果每个塔自己写死数值,后期调平衡要到四五个文件里去翻;集中成表后,改一个数字就能全局生效。
4.2 射击判定:射程半径、间隔计时与目标选择
塔防里的“塔打怪”不是物理层碰撞,而是每帧做一次纯数学判断。每座塔维护自己的冷却时间,冷却好了就遍历怪物列表算距离,找到最近或最靠前的目标开火。
void Tower::tryAttack(QVector<Monster*>& monsters, int elapsedMs) { m_coolDown -= elapsedMs; // 上次开火后开始倒计时 if (m_coolDown > 0) return; // 冷却没走完,不开火 Monster* target = nullptr; float bestProgress = -1.0f; for (Monster* monster : monsters) { if (monster->isDead()) continue; // 死怪不参与索敌 float dx = monster->pos().x() - pos().x(); float dy = monster->pos().y() - pos().y(); float dist = std::sqrt(dx*dx + dy*dy); if (dist <= range) { // 选择行进距离最远的怪,相当于“最接近终点的最优先” if (monster->getPathProgress() > bestProgress) { bestProgress = monster->getPathProgress(); target = monster; } } } if (target) { applyDamage(target, damage); m_coolDown = attackInterval; // 重置冷却 } }关键在于冷却用毫秒累加,不要用帧数判断——QTimer interval 调成 30ms 时,窗口大小变化或系统负载高,帧率会变,用帧数就会导致不同电脑打出的速度不同。这里射程 range 用的是像素级欧氏距离,range 设置 150~220 要和怪物移动速度 2~5 px/帧配得上,不然会出现“塔永远打不到怪”的诡异情况。
还有个新手常忽略的点:选择目标时用<还是<=决定同进度时谁被选中,这个顺序稳定即可,不是 bug。真正的 bug 常出现在怪物更新与塔攻击的先后顺序上——如果先让塔打一轮,再让怪物移动,同一帧里塔索敌时怪物还在原位,但投射物飞过去时怪物已经走开,画面会出现“追踪弹空放”。
4.3 升级曲线:升一级该花多少钱,攻击力怎么变
升级机制在这类塔防里是刚需:金币花出去要有正反馈,否则后期金币溢出,玩家没有操作空间。工程里的升级通常是把塔自身的等级和属性绑定,每次升级重新读取一次属性表。
const TowerProperty kTowerProps[3][4] = { // [等级][塔类型] 这里的顺序与 enum TowerType 对齐 { /* 1级四塔属性 */ }, { /* 2级四塔属性 */ }, { /* 3级四塔属性 */ } }; void Tower::upgrade() { if (m_level >= MAX_TOWER_LEVEL) { return; // 已满级 } m_level++; // 从表里刷新攻击间隔、伤害、射程三个字段 m_attackInterval = kTowerProps[m_level][m_type].interval; m_damage = kTowerProps[m_level][m_type].damage; m_range = kTowerProps[m_level][m_type].range; }升级费用如果直接写死常数也行,但课程设计想体现难度曲线,可以这样计算:cost = baseCost * (m_level + 1),即每一级都比上一级贵一倍基础价。关于升级还有一个常见坑:升级时塔身上的攻击特效、粒子对象要清理掉,老的特效对象如果不 delete,就会出现“升级后攻击力已经变了,但屏幕上的旧弹道还在飞”的视觉错位;这不是逻辑问题,但答辩演示时很掉分。
5. 避坑排查:这个塔防工程最容易翻车的五个地方
5.1 现象:双击运行后界面空白,图片和 BGM 全不出来
原因:qrc 里的资源路径和代码里的资源前缀对不上,或者 qrc 修改后没有重新执行 qmake,编译器继续用的还是旧的 qrc_resource.cpp。有同学在 vscode 配 C++ 环境时直接把 .qrc 文件当普通文本挪到别的路径,也会导致资源整体失踪。
解决:先开 Qt Creator 的“项目”页签,右键资源文件重新构建;再检查 prefix 是否为/,代码里统一用:/开头。更快的验证方法是把 BGM 加载做一个失败回调:
player->setMedia(QUrl("qrc:/audio/bgm_start.mp3")); connect(player, &QMediaPlayer::errorOccurred, this, [=](QMediaPlayer::Error err){ qDebug() << "音频加载失败:" << err; });5.2 现象:怪物已经死亡,防御塔还在对它开火、甚至还能打出伤害
原因:“怪物死亡”只是血量归零,没有把 dead 标志同步到塔的索敌列表;或者塔攻击回调里拿到的怪指针,实际上是堆上对象的同一地址,死亡后没有 remove。
解决:Monster 增加isDead(),塔的 tryAttack 循环里唯一判活条件就是它;同时在伤害结算函数里开火前再检查一次怪物是否已标记死亡。如果塔已经发射了投射物,要在投射物解析时判断目标是否还活着,死了就回收投射物,不结算。
5.3 现象:六条命明明扣光了,游戏却没有结束
原因:扣命逻辑只改了一个显示数字,没有真的停止 QTimer 和刷怪队列。更隐蔽的版本是结束条件被放在了渲染流程里,画面重绘被跳过,判定也随之丢失。
解决:结束游戏的唯一出口要收敛成一个函数endGame(bool win),函数内 stop 计时器、清空怪物列表、切失败页面。任何地方想判负都调它,不要各自写一套“游戏暂停”逻辑,否则早晚会出现命数归零但游戏还在跑的场面。
5.4 现象:塔升到二级后伤害没变,金币却扣了
原因:升级时把塔对象的值拷贝了一份去改,改的是副本而不是本体;或者升级函数传入的塔指针是const Tower*,内部走了隐式转换后又没刷新属性。
解决:升级逻辑统一走Tower::upgrade()成员函数,外部不直接改属性;如果用的是属性表,记得升级后从kTowerProps[m_level][m_type]重新读值,而不是在函数入口提前 return。建议升级前后打一行 qDebug 输出伤害,用日志确认数值变化:
qDebug() << "升级前 damage = " << m_damage; upgrade(); qDebug() << "升级后 damage = " << m_damage;5.5 现象:鼠标点击塔位时偶尔点到旁边的格子,高 DPI 屏幕尤其明显
原因:Qt 在高 DPI 下如果没开启高 DPI 缩放属性,逻辑坐标和物理坐标错位,点击事件带进来的坐标与实际渲染位置偏差;项目里没有做坐标归一化,直接用窗口事件坐标去匹配格子编号。
解决:在 main() 里设置高 DPI 属性,或者写一个统一坐标换算函数把鼠标坐标除以缩放系数:
// 取鼠标点击的格子编号时统一走一个换算 int col = (event->pos().x() - mapOriginX) / CELL_WIDTH; int row = (event->pos().y() - mapOriginY) / CELL_HEIGHT;这一类问题在写满图片素材的关卡里尤其明显,因为图片有自己的 margin 和 padding,格子命中区域不能只按视觉拿捏,必须以 CELL_WIDTH 为唯一换算标准。
6. 从还原到改造:三个能验证你看懂工程的改动方向
先别急着加新功能,动手前先做一件小事:把游戏结束时 mainwindow 里的 m_gameTimer、怪物列表、塔列表全部重置一遍,确认“重开一关”不会被上一次运行的野指针串台。每次重开都崩溃,这个现象一出现,多半是 delete 和 deleteLater 的位置没放对。
三个改造方向由易到难。第一步,给波次表加随机间隔——把 spawnManager 里写死的 intervalMs 改成基础值加 15% 波动,用 C++ 随机数生成每波差异,这样每次游玩节奏都不同,逻辑改一行,但能直观看出波次队列的独立性。第二步,给炮塔加“命中后溅射”的伤害衰减:炮弹命中目标时对半径 80px 内其他怪物减半伤害,这一步能验证你对塔攻击结算、投射物生命周期和怪物链表的理解深度。第三步,把“6 条命”做成资源条显示并加闪烁警告,血量低于 2 时画面变红——这一步会逼你去梳理扣命判定点是否唯一、HUD 刷新应该在 tick 里还是信号槽里,恰好是这份工程架构能力的试金石。
我记得自己刚拆完这套代码时,最得意的是往主循环里塞了一个 qDebug 打点,把每只怪的路径进度和塔的冷却值全部打出来,盯着输出看了一晚上。那之后我对塔防这类“事件循环驱动玩法”的 C++ 小游戏就有了一种本能——拿到任何课程设计工程,先跑一遍原包,再抓一次崩溃现场,最后顺着 qrc 资源路径检查一遍加载顺序,这套流程救了我很多次。希望帮到你,也祝你把它改成自己顺手的样子。
本文还有配套的精品资源,点击获取