ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++塔防游戏课程设计:基于Qt的保卫萝卜源码解析

C++塔防游戏课程设计:基于Qt的保卫萝卜源码解析 简介这是一份基于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; // 驱动刷怪、塔攻击的主循环 QVectorTower* 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/ fileaudio/bgm_start.mp3/file fileimages/monster_01.png/file fileimages/monster_02.png/file fileimages/tower_arrow.png/file fileimages/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 重新编译它只按 CtrlS 保存但忘记重新构建资源不更新也是常见翻车点。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 建立可以放塔的位置列表 QVectorQPoint 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)} QVectorQPoint path { QPoint(80, 80), QPoint(400, 80), QPoint(400, 400), QPoint(720, 400) }; // 怪物沿路点移动每帧步进 void Monster::moveStep(int speed, const QVectorQPoint 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 每隔几十毫秒触发一次 ticktick 里做三件事从波次队列取要生成的怪、给已有怪物调 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: QQueueWave 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)特点箭塔10010600200廉价单点前期主力炮塔15025900180高单发伤害攻速慢魔法塔1208300220极快速低伤范围塔20061000150对溅射范围额外结算这张表在工程里一般是数值常量数组不是散落在各个塔类的构造函数里。之所以强调是因为如果每个塔自己写死数值后期调平衡要到四五个文件里去翻集中成表后改一个数字就能全局生效。4.2 射击判定射程半径、间隔计时与目标选择塔防里的“塔打怪”不是物理层碰撞而是每帧做一次纯数学判断。每座塔维护自己的冷却时间冷却好了就遍历怪物列表算距离找到最近或最靠前的目标开火。void Tower::tryAttack(QVectorMonster* 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 资源路径检查一遍加载顺序这套流程救了我很多次。希望帮到你也祝你把它改成自己顺手的样子。本文还有配套的精品资源点击获取
返回列表