简介:基于C++实现的保卫萝卜塔防游戏,是一份适合C++进阶学习者与游戏开发入门者参考的课程设计完整项目。整体玩法与经典保卫萝卜高度一致,包含开始界面与背景音乐、三个关卡、多种出场路径,支持在指定位置建造四种防御塔,并对应五种怪物与逐波递增的难度。项目初始金币、击杀得金币、升级防御塔、六条生命及通关失败判定等核心机制均有体现,覆盖从游戏框架到资源加载的常见处理思路。
资源包共294个文件,压缩包约23.48MB,以cpp源码(66个)、h头文件(55个)、png图片(81个)为主,另含qrc资源管理、pro工程配置、plist配置及docx说明文档等,便于直接打开工程分析运行。目前已有483人学习下载。借助该工程可学习塔防游戏的关卡设计、路径与碰撞逻辑、UI及音频集成方式,也可作为课程设计改造与二次开发的基础模板。
1. 用C++把保卫萝卜从玩法拆成代码:这个练手项目到底在练什么
说句实话,用C++做“保卫萝卜塔防游戏”,最值钱的不是最后能跑起来的那个演示画面,而是从“我会写C++语法”到“我能把一个游戏拆成数据结构和逻辑”这个过程。塔防和贪吃蛇、飞机大战不一样,它天然有路径、怪物、炮塔、子弹、金币、血条这些互相影响的子系统,正好逼你把类的设计、容器的选择、循环的节奏都过一遍。这篇笔记面向两种人:一是要做C++课程设计、需要现场演示的同学;二是已经学过C++基础,想找个C++小游戏项目练手的开发者。我会按最小可玩版讲起,用控制台字符界面把整个游戏跑通,再给出往图形界面和完整玩法扩展的方向。
2. 保卫萝卜的玩法拆解:路径、炮塔、子弹和胜负判定怎么落到C++
2.1 地图为什么先用“网格+固定路径”而不是A*寻路
保卫萝卜看起来是“怪物走一条路”,但落到程序里,你首先要回答一个问题:地图上的路是动态算出来的,还是预先定好的?很多第一次写塔防的人,一上来就写A*寻路,结果地图都没画好,先被开放列表和启发函数折磨一遍。更务实的做法是:先做固定路径。
我一般把地图设计成一个二维字符数组,'.'表示可通行地面,'#'表示不可放置炮塔的障碍物,'S'是怪物出生点,'E'是终点。然后单独用一个std::vector<std::pair<int,int>>存路径点,按顺序把从起点到终点经过的格子列出来。怪物要走的,就是这条路。
// 地图尺寸:行数、列数 const int ROWS = 10; const int COLS = 15; // 用一维数组存地图,避免 vector<vector<char>> 的内存碎片 // 0 表示空地,1 表示障碍物,2 表示出生点,3 表示终点 int mapData[ROWS * COLS] = { // 这里是示意,实际初始化按关卡填 }; // 固定路径点:按顺序存储怪物的行走路线 std::vector<std::pair<int, int>> path = { {0, 0}, {0, 1}, {0, 2}, {1, 2}, {2, 2}, {2, 3}, {3, 3} };这段代码的逻辑很简单:怪物只认path,每一步朝下一个路径点移动,到了就把下标加一。为什么用std::pair<int,int>?因为路径点就是“行、列”两个整数,没有必要为此定义一个类。用一维数组存地图而不是二维数组,是为了减少多层vector的开销和误用,你在地图上取某个格子时写成mapData[row * COLS + col],后面写寻路或者做范围判定时会更好算。
提示:固定路径适合第一版,但它的缺点是路径写死在代码里,改关卡要改数组。如果你想做得灵活,后面可以换A*,我放在最后一章讲。
2.2 怪物、炮塔、子弹的类设计:继承与组合的边界
这是整个项目里最能体现C++功底的地方。我见过两种极端:一种是什么都塞进一个Game类,怪物、炮塔、子弹全用数组存,逻辑全写在update()里,几百行下来自己都找不到变量;另一种是疯狂用继承,建一个Entity基类,然后怪物继承、炮塔继承、子弹也继承,结果两个子类根本没有公共行为,还要强行维护虚函数表。
我的经验是:炮塔适合继承,怪物和子弹不适合。原因是炮塔在保卫萝卜里确实有分支,有的打单体,有的范围溅射,有的减速;它们的射击逻辑不同,但“攻击范围、攻击间隔、当前冷却”这些属性是公共的。而怪物和子弹,结构相对固定,用普通结构体反而更清楚。
#include <vector> #include <cmath> // 怪物:位置用浮点表示,走路径时才不会一格一跳 struct Monster { float row; // 当前所在行,允许小数 float col; // 当前所在列,允许小数 int pathIndex; // 正在走向第几个路径点 int hp; // 当前血量 int maxHp; // 最大血量,用于画血条 int reward; // 击杀后给的金币 float speed; // 移动速度,每秒走几个格子 bool alive; // 是否存活 }; // 子弹:飞向目标,位置和速度 struct Bullet { float row; float col; float targetRow; // 目标当前行,为了简化,可以追着目标位置打 float targetCol; float speed; int damage; }; // 塔的基类:公共属性放这里 class Tower { public: Tower(int r, int c, int range, int cooldown, int damage) : row(r), col(c), attackRange(range), maxCooldown(cooldown), damage(damage), currentCooldown(0) {} // 纯虚函数:不同塔的射击方式不同 virtual void shoot(std::vector<Monster>& monsters, std::vector<Bullet>& bullets) = 0; protected: int row; // 塔所在行 int col; // 塔所在列 int attackRange; // 攻击范围,以格子为单位 int maxCooldown; // 攻击间隔,单位:帧数 int currentCooldown; // 当前冷却剩余帧数 int damage; // 伤害值 };这里的关键参数是:attackRange决定塔能打到多远,maxCooldown决定攻击频率。注意我把“冷却”设计成帧数而不是秒数,是因为第一版主循环用Sleep固定帧率,按帧计数最简单;以后要改成毫秒,只需要在shoot里判断时间差,结构不用动。
多态不一定要用虚函数叫得震天响,但在这个场景里,Tower::shoot做成纯虚函数是划算的:你可以写一个SingleShotTower打最近怪物,再写一个SplashTower对范围所有怪物各发一炮。所有塔都用std::vector<std::unique_ptr<Tower>>存,调用时直接tower->shoot(...),新增炮塔类型时不用改主循环。
2.3 战斗结算顺序:先移动再攻击,避免空指针和无效伤害
游戏主循环里,update()内部的操作顺序是一个很容易被忽略但影响很大的细节。我的顺序是:刷新波次 → 怪物移动 → 炮塔攻击 → 子弹移动 → 伤害结算 → 清理死亡怪物 → 判断胜负。为什么必须“先移动再攻击”?因为如果炮塔先攻击,你打的可能是上一帧位置上的怪物;等怪物移动后,子弹的目标可能已经跑出屏幕,甚至已经被打死了,你还在往一个已经alive=false的怪物身上加伤害。
另外,子弹的命中判定也不能只算“到达目标点就算命中”。我用的方法是:每帧更新子弹位置,然后计算子弹与目标的距离,小于某个阈值就算命中。这种方法对速度高的子弹有缺点,可能会穿模,但在控制台塔防这种低帧率游戏里够用。
void updateGame(std::vector<Monster>& monsters, std::vector<Tower*>& towers, std::vector<Bullet>& bullets, int& gold, int& lives) { // 第一步:怪物移动 for (auto& m : monsters) { if (!m.alive) continue; moveMonster(m, dt); // 走到终点则扣血、删除怪物 if (m.pathIndex >= static_cast<int>(path.size())) { lives--; m.alive = false; continue; } } // 第二步:炮塔攻击 for (auto* tower : towers) { tower->shoot(monsters, bullets); } // 第三步:子弹移动和命中 for (auto& b : bullets) { moveBullet(b, dt); for (auto& m : monsters) { if (!m.alive) continue; double dist = std::hypot(b.row - m.row, b.col - m.col); if (dist < 0.3) { m.hp -= b.damage; b.speed = 0; // 让子弹变成“已失效” if (m.hp <= 0) { m.alive = false; gold += m.reward; } break; } } } }这段逻辑里,std::hypot是<cmath>里计算欧氏距离的函数,比你自己写sqrt(row*row + col*col)可读性好。注意dt是上一帧到这一帧的时长,通常用clock()计算,我下面一章给出完整写法。lives是剩余生命值,怪物到终点就减一,减到0游戏结束。
注意:清理死亡怪物我建议不要直接在遍历中做
erase,否则会导致迭代器失效,最好的做法是先标记alive=false,一帧结束后用remove_if集中清理。
3. 用控制台跑一个最小可玩版:主循环、移动和射击的三段核心代码
3.1 主循环中的Update/Render分离:塔防的帧节奏怎么定
控制台塔防游戏最容易出现的问题就是“逻辑和显示搅在一起”:一边移动怪物,一边cout输出地图,结果你根本分不清是怪物没动还是没画出来。解决方案很简单,把一帧分成输入、逻辑更新、渲染三步,帧之间用Sleep控制速度。
#include <iostream> #include <vector> #include <chrono> #include <thread> #include <cstdlib> int main() { // 初始化游戏对象,这里省略具体初始化代码 std::vector<Monster> monsters; std::vector<Bullet> bullets; std::vector<Tower*> towers; int gold = 100; int lives = 10; bool running = true; auto lastTime = std::chrono::steady_clock::now(); while (running) { // 计算这一帧的时间差,单位:秒 auto now = std::chrono::steady_clock::now(); float dt = std::chrono::duration<float>(now - lastTime).count(); lastTime = now; handleInput(running, towers, gold); // 输入:按下字母T放置炮塔 updateGame(monsters, towers, bullets, gold, lives, dt); // 逻辑更新 render(monsters, towers, bullets, gold, lives); // 渲染画面 // 控制帧率约为30FPS std::this_thread::sleep_for(std::chrono::milliseconds(33)); } return 0; }这里的参数dt是时间步长,作用是让移动速度不依赖帧率。如果你简单地把怪物每秒移动2格当成“每帧移动2格”,换个电脑跑速度快一倍,游戏难度完全不一样。用steady_clock而不是clock(),是因为clock()在某些平台是CPU时间,不是真实流逝时间;steady_clock才是单调递增的墙钟时间。
3.2 怪物移动与炮塔射击:浮点坐标和攻击冷却的代码实现
前面提到怪物位置用浮点,这里是核心代码。怪物的row和col是小数,比如{1.2, 3.0}表示它正在从第1行的格子走向第2行。移动时,我们要计算它和目标路径点的距离,向目标点方向前进。这个实现不能只在水平或垂直方向走,否则拐弯时会卡住。
void moveMonster(Monster& m, float dt, const std::vector<std::pair<int,int>>& path) { if (m.pathIndex >= static_cast<int>(path.size())) { return; // 已经没有路径点了 } auto target = path[m.pathIndex]; float targetRow = static_cast<float>(target.first); float targetCol = static_cast<float>(target.second); float dRow = targetRow - m.row; float dCol = targetCol - m.col; float dist = std::hypot(dRow, dCol); // 如果距离足够近,就认为到达了路径点,进入下一个 if (dist < 0.1f) { m.row = targetRow; m.col = targetCol; m.pathIndex++; return; } float step = m.speed * dt; m.row += dRow / dist * step; m.col += dCol / dist * step; }这段代码的关键是归一化方向向量:dRow / dist和dCol / dist共同组成单位向量,乘以步长后,怪物无论朝哪个方向走,速度都是恒定的。dist < 0.1f这个阈值要设置合理,太小可能导致怪物在浮点精度下永远“差一点点”,太大则会让怪物提前拐弯、看起来没贴住路线。
炮塔的射击逻辑比移动更麻烦的是冷却和选目标。我习惯在Tower的shoot里先检查冷却,再遍历怪物列表选目标。选目标的规则是:范围内、存活、路径索引最大(走得最远的怪物优先)。这个规则是保卫萝卜类游戏的通用做法,避免炮塔死盯着刚出生的怪物打。
class SingleShotTower : public Tower { public: SingleShotTower(int r, int c) : Tower(r, c, 3, 20, 10) {} void shoot(std::vector<Monster>& monsters, std::vector<Bullet>& bullets) override { if (currentCooldown > 0) { currentCooldown--; return; } // 找范围内、路径最靠后的存活怪物 Monster* target = nullptr; for (auto& m : monsters) { if (!m.alive) continue; float dist = std::hypot(m.row - row, m.col - col); if (dist <= attackRange) { if (target == nullptr || m.pathIndex > target->pathIndex) { target = &m; } } } if (target != nullptr) { Bullet b; b.row = row; // 塔中心就是子弹起点 b.col = col; b.targetRow = target->row; b.targetCol = target->col; b.speed = 8.0f; b.damage = this->damage; bullets.push_back(b); currentCooldown = maxCooldown; // 重置冷却 } } };注意currentCooldown = maxCooldown意味着塔每20帧最多射一发,在30FPS下大约0.67秒一次。attackRange=3表示能打到以塔为中心、半径3格内的怪物。这些参数你要在测试时反复调:范围大了塔太强,小了又没人愿意造。调试时可以把它们变成可配置的,通过命令行参数输入,省得每次重编译。
提示:子弹的
targetRow和targetCol是发射瞬间固定的,这会让子弹瞄准怪物“之前的位置”。对于移动慢的怪物影响不大,但如果你做高速怪物,子弹会跟在屁股后面追。更进阶的做法是每帧在moveBullet里更新目标坐标,让子弹有追踪效果。
3.3 控制台渲染:整帧重绘,避免字符画面闪烁
控制台没有现成的画布,所有“画面”都是字符。最简单的做法是每次渲染时system("cls")然后重新打印,但这样会剧烈闪烁,眼睛很难受。我推荐用 Windows API 的SetConsoleCursorPosition,把光标移动到控制台左上角再重绘,相当于整帧覆盖。
#ifdef _WIN32 #include <windows.h> void render(const std::vector<Monster>& monsters, const std::vector<Tower*>& towers, int gold, int lives) { // 把光标移到左上角,实现无闪烁覆盖 HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE); COORD cursorPos = {0, 0}; SetConsoleCursorPosition(hConsole, cursorPos); // 这里用字符画地图,具体可以打印一个 20x30 的字符缓冲区 // 我们维护一个 char screen[ROWS][COLS],把格子内容填进去再输出 // 怪物显示为 'M',炮塔显示为 'T',空地显示为 '.' // 先输出信息栏 std::cout << "Gold: " << gold << " Lives: " << lives << " \n"; } #endif这里的思路是:不用cls清屏,而是移动光标到(0,0)后覆盖输出。注意输出行数如果比上一帧少,最后会残留旧字符,所以每行字符串后面要补空格,或者用一个足够大的空白行去覆盖。这个方法依赖 Windows 控制台 API,如果你用 VSCode 配置 C/C++ 环境时选了 MinGW,唯一要注意的是控制台字体和编码问题,另一个是setlocale(LC_ALL, "zh_CN.UTF-8"),否则中文字符会乱码。
3.4 编译运行与参数说明:MinGW、VS Code和运行时库
写完代码后,编译命令要简洁。如果你用的是 MinGW-w64,在项目目录执行:
g++ -std=c++17 -Wall -Wextra main.cpp -o tower_defense.exe说明:-std=c++17开启C++17标准,主要用到std::optional和结构化绑定;-Wall -Wextra开全警告,新手写游戏最容易忽视警告,等运行时才发现问题。如果你在 VS Code 里配置好了 C++ 环境,直接在终端调g++即可。如果你是在 Visual Studio 里,注意把项目字符集设为“多字节字符集”,因为SetConsoleCursorPosition和中文输出混用时,UTF-8 或 Unicode 容易打架。
运行时如果提示缺少 DLL,比如libgcc_s_seh-1.dll或libstdc++-6.dll,这是 MinGW 环境变量没配好。最简单的处理是把 MinGW 的bin目录加入系统 PATH,或者把这两个 DLL 复制到 exe 同目录。有些机器上会看到“Microsoft Visual C++ Redistributable”相关的错误,那是用 MSVC 编译的版本才需要;MinGW 带的是自己的运行时,不依赖微软的运行库。区分清楚这一点,排查时就少走弯路。
4. 塔防项目避坑清单:5个常见翻车点与排查顺序
4.1 怪物到终点不掉血:终点判定发生在移动逻辑之前
现象:怪物明明走到了终点,控制台里也看不到它了,但lives一个都不少。
原因:代码里写的是if (m.pathIndex >= path.size()),这个判断放在了moveMonster之前。怪物上一帧还在路径最后一个点,还没移动,pathIndex仍然小于path.size(),所以不触发扣血;下一帧先执行了移动,pathIndex变成path.size(),但判断已经在前面跑过了,依然不扣血,于是怪物悄悄消失。
解决:把终点判定放到moveMonster之后,或者干脆移动到终点后立刻检查。更保险的做法是moveMonster内部在pathIndex++之后立刻返回一个bool表示是否到达终点,主函数根据这个返回值扣血。
// 在 moveMonster 里,到达终点时返回 false bool moveMonster(Monster& m, float dt, const std::vector<std::pair<int,int>>& path) { // ... 判断和移动逻辑 ... if (dist < 0.1f) { m.pathIndex++; return m.pathIndex < static_cast<int>(path.size()); } return true; }4.2 控制台画面闪烁:cout刷新和双缓冲的差异
现象:怪物一多,控制台整个画面疯狂抖动,看不出来谁是谁。
原因:你用了system("cls")或每行cout << endl,而endl会强制刷新输出缓冲区。控制台程序在每次cout后刷新,会造成大量无意义的I/O操作,再加上cls清除后留下的空白,视觉上就是闪烁。
解决:第一是整帧只刷新一次,把要显示的内容拼成一个std::string,最后一次性std::cout << screenData;第二是用前面提到的SetConsoleCursorPosition回到原点覆盖,不用cls。如果是在 Linux 终端,可以用 ANSI 转义序列\033[H移动光标,效果类似。注意输出字符串的长度要覆盖上一帧的内容,否则边缘会有残留。
4.3 炮塔在射程内却不攻击:像素距离与网格距离混用
现象:炮塔放在路边,怪物从旁边经过,距离肉眼看很近,但塔就是不攻击。
原因:塔的地图坐标是格子索引,比如塔放在{3, 4},意思是第3行第4列的格子中心;而怪物的坐标是浮点数,比如{3.4, 4.7}。如果你计算距离时用了m.row - tower.row这个整数减浮点,其实已经把塔的位置当成格子左上角了,半格偏差累积起来,边界上的怪物就永远“差一点”。
解决:统一成格心坐标。计算距离前,把塔的行列各加0.5再算,或者在Tower内部保存格心浮点坐标。最好在创建塔的时候就存好中心点:
tower->centerRow = row + 0.5f; tower->centerCol = col + 0.5f;然后所有距离计算都从centerRow和centerCol出发。这是我在调参过程中最典型的玄学问题之一,看起来像随机Bug,其实是坐标系没统一。
4.4 随机崩溃:数组越界把路径点和地图边界对齐
现象:程序跑着跑着突然崩溃,有时在杀完一波怪物后,有时在放第三座塔的时候,错误信息像Access Violation C0000005。
原因:路径点坐标和地图边界没有对齐。比如地图是 10行15列,但路径点在初始化时写了一个{11, 2}。怪物走到第11行时,渲染函数直接把它的坐标当成数组下标写进屏幕缓冲区,越界访问内存,轻则花屏,重则崩溃。
解决:第一,路径点初始化后立刻做一次合法性检查,逐点确认在[0, ROWS) × [0, COLS)内;第二,把渲染函数里的地图访问改成带边界判断的形式;第三,调试时用std::vector::at()代替operator[],越界会抛异常而不是静默崩溃。上线性能版本再换回operator[]。我在课设阶段靠这一招找出了三个越界点,都是path写错了数字。
4.5 刷怪波次一多就卡:全量遍历的复杂度问题
现象:怪物数量到50只左右时,游戏明显变卡,放一座塔要顿一下。
原因:每帧都做这样的循环:所有怪物移动、每座塔遍历所有怪物选目标、每颗子弹再遍历所有怪物检测碰撞。复杂度是O(怪物×塔 + 子弹×怪物),怪物一多,指数级变慢。
解决:先做两个低成本的优化。第一,塔在选择目标时不要遍历所有怪物,只遍历“路径索引大于某个值”的怪物,或者维护一个按pathIndex排序的怪物列表;第二,子弹命中检测只检查与目标怪物的距离,而不是所有怪物。如果还慢,可以把地图分区块,怪物只登记在它所在的区块里,塔找目标时只搜附近区块。对于课设来说,前两个优化已经够用,不用一上来就写四叉树。
注意:性能优化要放到功能能玩之后再做。我见过很多人在第一版就做了复杂的空间分区,结果逻辑没跑通,排查难度翻倍。先能玩,再算快。
5. 把“能玩”做成“像保卫萝卜”:寻路、随机刷怪和存档的验证方向
5.1 用A*和小顶堆做动态路径
固定路径只是最小可玩版。想更接近保卫萝卜的关卡设计,你需要让怪物在遇到障碍时自己找路。常见做法是A寻路:地图每个格子作为节点,怪物从起点到终点找一条最短路径。A的C++实现一般搭配优先队列std::priority_queue,估算函数用曼哈顿距离。有了A*之后,你在任意位置放路障改变地图,怪物就会重新规划路径,这是塔防游戏里“地形改造”玩法的基础。
路径搜索不要每帧都跑,只在“地图发生变化”或者“怪物出生时”跑一次,把结果存成路径点列表,怪物仍然按照固定路径那套逻辑移动。这样性能不会崩。
5.2 用C++随机数做刷怪波次:rand到mt19937
出怪顺序如果完全写死,玩两遍就没意思了。我一般把一波怪的组成定义成结构体,比如普通怪10只、快速怪5只、BOSS 1只,每只怪的具体出现间隔用随机数决定。C++里rand()虽然能用,但质量差且需要srand配合;更推荐<random>库的std::mt19937,随机数种子可以用std::random_device生成,这样每次开局的出怪细节都有变化,但仍保证每一波的总难度符合预期。
std::mt19937 rng(12345); // 固定种子方便调试 std::uniform_int_distribution<int> dist(30, 90); // 出怪间隔,单位:帧 int interval = dist(rng);注意固定种子和随机种子两种模式:调试时用固定种子,能复现问题;发布演示时再换成随机种子,否则评审老师看完第一局,第二局一模一样就露馅了。
5.3 存档与恢复:序列化后怎么校验
保卫萝卜中途退出再进来还得继续,这就是存档功能。最简单的存档是把金币、波次、怪物、塔分布这些数据按行写入文本文件。C++的std::ifstream和std::ofstream很容易处理,但要注意:读取时不能假设文件一定存在且格式正确。我后来养成一个习惯,写存档时先写一个“魔法数”和版本号,读入时先校验,不匹配就直接当文件损坏处理,返回主菜单,而不是硬着头皮解析,否则一堆未初始化的变量会让游戏行为变得不可理喻。
用字符串解析时,std::stringstream就够用,别自己写字符拆分。像“地图上哪几个格子放了什么塔”这种数据,最省事的是把塔的坐标和类型一行一个存:3 4 1表示第3行第4列放1号塔。
我自己做这个项目时栽得最深的一次,是花了整个下午调试一个“第三波怪物突然不动”的Bug,最后发现是路径点数组少写了一个拐弯,怪物卡在两个路径点之间来回横跳。从那以后,我养成了一个习惯:任何游戏逻辑的调整,都先跑一局固定种子、固定操作的测试局,记录关键状态,再改代码。这个方法帮我在后续所有的玩法迭代里都省下了大量时间。这个方向确实值得投入,因为塔防游戏麻雀虽小,却能把C++的语法、数据结构和工程习惯串成一条线,希望帮到你。
本文还有配套的精品资源,点击获取