
简介这是一份基于C与Qt 5.14.1编写的坦克大战游戏大作业适合正在学习面向对象编程、Qt图形界面开发或需要完成课程设计的学生。压缩包大小约27.79MB内容主要包含Qt工程源码、C源文件、头文件及项目配置并且已在gcc 7.3.0与Qt Creator 4.11.0环境下调试通过。目前已有1152人浏览学习作为单人游戏开发示例参考价值较好。游戏共设35关每关需击败20辆敌方坦克玩家拥有3条命通过WASD键控制坦克移动J键发射子弹敌方坦克自动巡控射击击败全部敌人进入下一关通关35关即胜利生命归零或大本营被击中则失败。读者可从源码中梳理Qt事件循环、键盘响应、绘图与碰撞检测、敌方AI移动及关卡状态管理等关键模块也能移植为自己的大作业或基于其框架继续扩展玩法与关卡整体结构清晰便于后续维护与二次开发。1. C大作业坦克大战Qt 5.14.1写的35关完整游戏源码和玩法全拆开讲期末C大作业十有八九是控制台里打印菱形、做个学生管理系统能拿上台面的不多。这份坦克大战不一样它用Qt把经典玩法完整搬到了桌面上共35关每关20个敌方坦克玩家3条命WASD控制移动、J键发射子弹清完20个敌人自动进入下一关生命归零或大本营被击中直接失败。对C课程设计来说是明显的加分项对想学Qt游戏事件循环、碰撞检测、简单AI的人来说又是一份可以直接落地复现的完整源码。这篇笔记按类设计、碰撞判定、关卡AI、编译避坑的顺序把它拆透。2. 项目拆解坦克、子弹、地图的类设计以及QTimer驱动的游戏主循环先下个结论这份工程的骨架比一般大作业干净它把游戏实体拆成坦克玩家和敌人、子弹、地图三块由Game类统一持有再用一个QTimer把整个游戏逻辑串起来。拆开的好处是每个类职责单一碰撞检测时拿坦克的矩形去和地图格子、子弹做比对不用在游戏主类里堆几百行if判断。2.1 为什么把坦克、子弹、地图拆成独立类而不是全写在一个cpp里大作业最常见的翻车写法是把坦克坐标、子弹数组、地图数组全塞进MainWindow结果改一个功能牵动全局。这份项目用了继承Tank是基类PlayerTank和EnemyTank分别处理玩家输入和AIBullet独立管理飞行GameField负责地图格子和阻挡判断。分工大致如下类/模块主要职责关键成员Tank位置、方向、移动、存活状态x, y, dir, speed, alivePlayerTank / EnemyTank玩家按键处理 / 敌方AIlives / homeX, fireProbBullet飞行、命中判定、销毁x, y, dir, speed, aliveGameField地图二维数组、阻挡判断mapData[][], row, col, cellSizeGame主控定时器、关卡、胜负level, kills, gameStateTank基类的头文件长这样// Tank.h坦克基类玩家和敌人共用同一套移动逻辑 #ifndef TANK_H #define TANK_H #include QRect class GameField; class Tank { public: enum Direction { Up, Down, Left, Right }; Tank(int startX, int startY, int speed); virtual ~Tank() {} void setDirection(Direction dir); bool move(GameField* field); // 返回false说明撞墙位置不变 QRect rect() const; // 返回当前矩形供碰撞检测使用 bool isAlive() const { return alive; } void setAlive(bool v) { alive v; } protected: int x, y; // 左上角坐标像素单位 Direction dir; // 当前朝向 int speed; // 每帧移动像素数 bool alive; // 存活标记 int width, height; // 坦克尺寸通常等于格子大小 }; #endif几个点值得注意。第一move()的返回值设计成bool撞墙返回false调用方就能决定“随机换向”还是“原地不动”敌方AI正好用这个返回值做兜底。第二rect()统一返回QRect后续所有碰撞检测都拿它的返回值和地图格子、子弹做相交判断不用为坦克单独写一套坐标换算。第三构造函数里的speed是每帧像素数不是每秒速度因为游戏逻辑由定时器一帧一帧推动这个约定贯穿整个项目。2.2 游戏主循环QTimer、键盘事件和状态机的配合Qt游戏和命令行程序最大的区别是不能用while(true)死循环堵住主线程否则窗口会假死。这份项目用的是QTimer定时器驱动每30毫秒触发一次tick()把所有游戏逻辑推进一帧。为什么不用QThread再开一个游戏线程因为Qt的QWidget绘制必须在主线程的事件循环里执行子线程里直接动窗口控件不仅麻烦还会遇到线程安全和频繁刷新卡顿的问题。QTimer把逻辑推进和界面刷新合在同一个线程里对坦克大战这种低负载游戏完全够用代码也简单得多。// Game构造函数中的定时器初始化 gameTimer new QTimer(this); connect(gameTimer, QTimer::timeout, this, Game::tick); gameTimer-start(30); // 30ms一帧约33FPS// Game::tick()一帧内完成移动、命中、过关判定 void Game::tick() { player-move(field); // 1. 玩家按当前方向移动 for (auto* e : enemies) e-move(field); // 2. 敌人移动AI在各自类里处理 for (auto b : bullets) { b.move(field); // 3. 子弹移动 checkBulletHits(b); // 4. 子弹命中检测 } update(); // 5. 触发paintEvent重绘 }这里有个约定要遵守按键事件只改意图不直接改坐标。也就是说keyPressEvent里收到W键只把玩家的方向标志改成Up真正的位置变化全部发生在tick()的move()调用里。好处是键盘输入再快也不会让坦克走位失控同一帧内所有物体按固定顺序更新不会出现“这帧按了键但没生效”的错位感。键盘事件的写法void Game::keyPressEvent(QKeyEvent* event) { switch (event-key()) { case Qt::Key_W: player-setDirection(Tank::Up); break; case Qt::Key_S: player-setDirection(Tank::Down); break; case Qt::Key_A: player-setDirection(Tank::Left); break; case Qt::Key_D: player-setDirection(Tank::Right); break; case Qt::Key_J: player-fire(); break; } }补充一点这里只响应WASD和J没有调用event-accept()也不会出大问题但规范做法是收到已知按键后调用一下避免事件继续向父控件传播。玩家fire()里最好判断子弹数量上限常见做法是同一时刻场上只保留一发玩家子弹防止连续按J刷出一整排子弹把碰撞逻辑的耗时打上去。2.3 绘制顺序与坐标系paintEvent里的先后关系绘制是另一个容易翻车的点。Qt的QWidget绘制必须全部集中在paintEvent里顺序就是遮挡顺序先画的沉底、后画的浮在上面。这份游戏的paintEvent大概是这个结构void Game::paintEvent(QPaintEvent*) { QPainter painter(this); drawMap(painter); // 1. 地图砖墙、钢墙、水、草丛 drawTanks(painter); // 2. 坦克玩家和敌人 drawBullets(painter); // 3. 子弹 drawHUD(painter); // 4. 顶部信息关卡、生命数、击杀数 }如果把drawMap放到最后坦克会被地图盖住屏幕上只剩一堆墙在移动这是典型的自找麻烦。另外QPainter的坐标原点是窗口左上角向右向下为正。HUD文字绘制通常用painter.drawText指定一个QRect让文字居中关卡号和生命数建议画在窗口顶部预留的一条固定区域避免和地图格子错位。提示地图、坦克、子弹的坐标系必须保持一致。最省心的做法是所有实体都存像素坐标只在碰撞检测时临时换算成格子下标千万别一半存格子下标、一半存像素那种代码调试起来非常折磨。把这一层理顺下一步就是碰撞检测和胜负判定的细节。3. 碰撞检测与胜负判定WASD输入到游戏结束的完整链路坦克大战的玩法说到底是碰撞的堆叠坦克撞墙、子弹撞墙、子弹撞坦克、子弹撞大本营。把这几种碰撞用同一套矩形检测统一起来整个游戏的逻辑就清晰了。3.1 矩形碰撞与地图格子QRect::intersects和格子下标换算坦克是否撞墙本质是判断“坦克移动后的矩形”和“地图上的阻挡格子”是否相交。常见做法是拿坦克矩形覆盖的格子范围做遍历而不是遍历整张地图。下面这段是canMove的典型实现bool Tank::canMove(int newX, int newY, GameField* field) { QRect target(newX, newY, width, height); int x0 target.left() / field-cellSize; int y0 target.top() / field-cellSize; int x1 target.right() / field-cellSize; int y1 target.bottom()/ field-cellSize; for (int row y0; row y1; row) { for (int col x0; col x1; col) { if (field-isBlocked(row, col)) return false; // 有阻挡本次移动无效 } } return true; }参数含义newX和newY是坦克尝试移动到的目标左上角坐标cellSize是格子边长推荐32。target.right()除以cellSize得到矩形右边界所在的列号这个换算保证坦克即使没有完全对齐格子也能正确判断自己压到了哪些格子。为什么用QRect::intersects而不是自己写边界比较因为Qt已经处理了“刚好擦边”这类边界情况大作业里直接用现成API能少踩很多边界条件的坑。isBlocked的实现约定通常是0是空地1是砖墙子弹可摧毁2是钢墙子弹打不穿3是水域坦克不能进4是草丛坦克可进入但视觉上被遮挡。这套数字编码贯穿地图数据和碰撞判断格式统一后关卡设计省力很多。子弹的碰撞也复用同一个QRect。子弹本身是一个8×8或更小的矩形坦克是32×32的矩形两者相交就是命中。子弹的判定用矩形中心所在的格子做墙体检出配合矩形相交做对象命中两套检测分开但共用同一套坐标换算这是同类游戏里最不容易出错的组合。3.2 从按键到胜负子弹命中、生命扣除、过关和失败判定碰撞检测除了坦克对墙还有子弹对一切。每帧子弹移动后要依次和玩家坦克、敌方坦克、地图墙体、大本营做相交判断。这类判定写在一个函数里统一处理顺序很重要void Game::checkBulletHits(Bullet b) { if (!b.isAlive()) return; // 子弹与地图墙体用中心点所在的格子判断 if (field-isBlocked(b.centerY() / field-cellSize, b.centerX() / field-cellSize)) { b.setAlive(false); // 如果是砖墙这里调用 field-breakBlock(...) 破坏墙体 return; } // 子弹与玩家敌方子弹才能击杀玩家 if (!b.isPlayerBullet() b.rect().intersects(player-rect())) { b.setAlive(false); player-loseLife(); if (player-lives() 0) gameOver(); return; } // 子弹与敌方坦克玩家子弹才能击杀敌人 if (b.isPlayerBullet()) { for (auto* e : enemies) { if (e-isAlive() b.rect().intersects(e-rect())) { e-setAlive(false); b.setAlive(false); kills; break; } } } // 子弹与大本营任何子弹命中都直接失败 if (b.rect().intersects(baseRect)) { gameOver(); } }这里的关键约定玩家子弹不检测和玩家的碰撞敌方子弹不检测和敌方的碰撞否则刚开火就自己打死自己。代码里用isPlayerBullet()区分归属玩家子弹才允许击杀敌人敌方子弹才允许击杀玩家双方子弹都能摧毁砖墙和攻击大本营。过关和失败判定放在tick()末尾更合理。每关敌人总数是20击杀数等于20且玩家存活关卡号加一重新初始化敌人和地图玩家生命为0或者大本营rect被任何子弹命中游戏直接切到失败状态。注意“大本营被击中”的判定放在子弹检测的最后并且一旦命中立即返回防止同一帧内多颗子弹命中大本营后产生重复结算。3.3 高速子弹穿透薄墙拆步移动是唯一的后悔药这块是很多同类项目里最容易翻车的地方。子弹每帧移动距离大于墙的厚度时上一帧还在墙的左边下一帧已经跑到墙的右边矩形检测根本碰不到墙子弹直接穿过去打掉大本营。要解决这个问题常见做法是把一帧的移动拆成多个小步每步走完都做一次碰撞检测// 子弹每帧移动bulletSpeed像素拆成steps小步 int steps bulletSpeed / 2; // 每小步不超过2像素 int stepX (dir Right) ? 2 : (dir Left ? -2 : 0); int stepY (dir Down) ? 2 : (dir Up ? -2 : 0); for (int i 0; i steps b.isAlive(); i) { b.x stepX; b.y stepY; if (field-isBlocked(b.centerY() / field-cellSize, b.centerX() / field-cellSize)) { b.setAlive(false); // 中途撞墙立即销毁不再继续走 break; } }参数怎么定如果子弹速度是8像素/帧拆成4步、每步2像素墙的厚度至少有一个格子32像素所以2像素的步长永远不会产生穿越。注意判断用的是子弹中心点而不是左上角这样子弹擦着墙边飞过时不会误判成撞墙。4. 关卡系统与敌方AI35关难度曲线和敌人自动控制35关是这份项目最亮眼的设定。每关都是20个敌人、3条命比例固定难度靠什么递增答案在地图布局和敌人参数上。这块拆开讲就是两件事地图数据怎么存敌人AI怎么控制。4.1 关卡地图的数据结构二维数组与格子坐标换算地图是游戏的骨架。经典坦克大战的场地是13×13格这份项目沿用这个规格很合理窗口尺寸用13乘以cellSize32得到416像素再留一点边给HUD。地图数据最常见的组织方式是二维数组用数字代表地形// 第1关地图片段0空地 1砖墙 2钢墙 3水域 // 13列 x 13行cellSize32 const int level1Map[13][13] { {1,1,1,1,1,1,1,1,1,1,1,1,1}, {1,0,0,0,0,0,0,0,0,0,0,0,1}, {1,0,0,0,0,0,2,0,0,0,0,0,1}, {1,0,0,0,1,0,0,0,1,0,0,0,1}, {1,0,0,0,1,0,0,0,1,0,0,0,1}, {1,0,0,0,0,0,0,0,0,0,0,0,1}, {1,0,0,0,0,0,3,0,0,0,0,0,1}, // 中间行省略完整35关地图在源码包里按数组排列 };为什么第一行和最后一圈全是1因为地图边界本身就是砖墙这样写可以省掉单独的边界碰撞判断isBlocked只要查数组即可。后面关卡里钢墙逐渐增多、水域面积变大玩家子弹打不穿钢墙、坦克进不了水域路线自然受限难度随之上升。地图和碰撞的衔接有个容易忽略的细节地图数组的行号对应像素Y坐标除以cellSize列号对应X坐标除以cellSize。这份项目的所有碰撞代码都用这个换算改地图内容时不需要动代码但如果你为了好看把格子改成48像素那field-cellSize、坦克尺寸、绘图坐标三处必须一起改漏一处就会出现“坦克钻进墙里半截”的怪象。4.2 敌方坦克AI随机转向、概率开火和撞墙兜底敌方坦克自动控制说穿了就是三个决策什么时候转向、什么时候开火、卡住了怎么办。合理的设计是把这三个决策都做成概率加兜底而不是写复杂的状态机。下面是一个典型的敌人AI更新函数void EnemyTank::aiUpdate(GameField* field) { // 概率开火fireProb是当前关卡赋予的概率单位是百分比 if (qrand() % 100 fireProb) { fire(); } // 概率换向turnProb低一点避免敌人原地乱转 if (qrand() % 100 turnProb) { setDirection((Direction)(qrand() % 4)); } // 实际移动撞墙时move返回false此时强制换向 if (!move(field)) { setDirection((Direction)(qrand() % 4)); } }参数经验值fireProb从5起步后期可以涨到11左右turnProb取8到10太高会让敌人看起来非常神经质。move(field)是关键兜底——敌人往前走撞墙后返回falseAI立刻随机换一个方向这样即便某个出生点被墙围死敌人也不会卡在原地不动。出生点一般固定三个位置敌人刷新时随机选一个避免所有敌人从同一个点挤出来。还有一个所有同类项目都会踩的坑场上敌人不能一次性全部刷新否则玩家会被围在出生点。常见做法是场上最多同时存在4个敌人每消灭一个过一小段时间再补一个新敌人直到本关20个全部出场。4.3 35关的难度递进参数表驱动而不是重写地图35关如果全靠手绘地图工作量会非常恐怖而且容易让前后关卡难度跳跃。更省事的做法是用关卡号n推算出本关的敌人参数地图只负责提供地形差异难度增长交给数值。下面是一套典型的推算公式// 根据关卡号计算本关敌人的移动速度和开火概率 int enemySpeed 2 (n - 1) / 8; // 每8关增加135关时到6 int enemyFire 5 (n - 1) / 5; // 每5关增加1个百分点35关时到11 int enemiesPerLevel 20; // 每关敌人总数固定不变 int playerLives 3; // 玩家命数固定不变为什么这样分档玩家需要在前几关建立操作手感速度突然从2跳到5会让难度曲线断崖式上升每8关加1档速度、每5关加1档火力搭配地图里越来越多的钢墙和水域35关整体走的是一个缓坡。这不是原项目里白纸黑字写明的公式但如果你自己做二开照着这个思路设计参数会比逐关手工调数字省力得多也更容易让人觉得“这游戏是设计过的”。5. 编译运行与常见问题排查Qt Creator环境复现和四个实战坑代码再好编译环境搭不对也是零。这里先从环境复现开始再列几个实际遇到和最常见的Qt报错每一条都按现象、原因、解决三个步骤写清楚。5.1 环境复现Qt 5.14.1、gcc 7.3.0与.pro工程配置原项目环境是Qt 5.14.1、gcc 7.3.0、Qt Creator 4.11.0。这个组合到现在依然成熟可靠Windows上安装Qt时选择MinGW 7.3.0 64位套件基本就能对上。注意Qt安装包里会同时提供MinGW和MSVC两套编译器选MinGW是因为它和gcc同源不依赖Visual Studio装完就能用。有人想用VS Code配C/C插件改代码但Qt的工程文件、UI资源、构建套件在Qt Creator里处理最顺建议主力用Creator。新工程直接建Qt Widgets Application再把源码文件按类放进去最终.pro文件大致是这样QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET TankBattle TEMPLATE app SOURCES \ main.cpp \ game.cpp \ tank.cpp \ enemy.cpp \ bullet.cpp \ field.cpp HEADERS \ game.h \ tank.h \ enemy.h \ bullet.h \ field.h这里最容易踩的坑是Qt5里忘了加widgets模块。Qt4时代core和gui两个模块就够Qt5把窗口相关拆到了QtWidgets里缺了这一行编译会直接报“找不到QApplication头文件”。另一件容易被忽略的事如果之前用MSVC套件建过工程又切换到MinGW套件一定要删掉构建目录重新qmake否则缓存里的旧路径会牵着一堆错误不放。提示如果你的Qt安装目录和原项目不一致编译时出现形如“dependent ....\Qt\5.15.2\msvc2019_64\include\QtWidgets”的报错不要急着改代码先检查构建套件和Qt版本是否匹配这是编译器路径残留问题。5.2 编译与运行中的四个典型问题问题1编译通过双击exe提示缺少Qt5Cored.dll现象程序在Qt Creator里按F5能跑但单独打开构建目录里的exe就报“无法启动此程序因为计算机中丢失Qt5Cored.dll”。原因Qt的bin目录没有加入系统PATHexe找不到动态链接库。解决把Qt安装目录下的bin路径加到系统环境变量PATH里并重启终端或者构建完成后在构建目录执行Qt自带的windeployqt命令行工具它会自动把所有需要的dll复制到exe旁边这对后续分发更友好。问题2打开工程直接报依赖错误一堆路径指向别人的Qt目录现象报错信息里出现“dependent ............\Qt\5.15.2\msvc2019_64\include\QtWidgets”这类长路径但自己的机器上根本没装5.15.2或者装的是MinGW版本。原因工程文件或构建缓存里残留了原作者机器上的Qt库路径编译器切换后这些绝对路径全部失效。解决这是Qt圈子里公认的老玄学。先检查“工具→选项→构建套件”确认当前Kit用的是你自己的Qt版本和编译器然后删除工程根目录下的build开头文件夹右键工程执行“清除”再“构建”。如果还报错手动打开.pro文件检查是否有硬编码的INCLUDEPATH。问题3坦克移动后留下拖影画面像残影失控现象坦克每走一步旧位置还留有半透明的影子整个窗口越来越花。原因绘制代码写在了paintEvent外面或者paintEvent里创建QPainter后没有先绘制背景。QWidget默认双缓冲但只有每帧完整重绘背景时才会干净。解决所有绘制集中到paintEvent并且第一行先画背景色或地图。不要在tick()里直接调用painter.drawRect之类那会把内容画到缓冲区之外。这个坑看似简单但很多人在调绘图时都会犯一次。问题4按住W键坦克不动或只动一格现象按一下W坦克挪一点按住不放反而连续走或完全不响应。原因keyPressEvent里直接对坐标做了位移却没有配合定时器或者按键被当成一次性事件处理系统键盘重复触发不稳定。解决按键事件只改方向标志实际移动全部放到tick()的move()里。前面2.2节写的就是这个模式坚持这个约定键盘输入和游戏逻辑就彻底解耦。5.3 运行验证清单交作业前快速过一遍环境搞定后建议按下面的清单手动验证一遍十分钟就能确认这份代码在你的机器上没有暗坑检查项操作方法预期结果编译状态Creator构建输出窗口0 error0 warning玩家移动按WASD坦克平滑移动方向正确射击与墙体对准砖墙按J子弹命中后砖墙消失子弹销毁生命扣除故意让敌弹命中玩家顶部生命数减1归零后失败画面过关条件消灭20个敌人自动进入下一关并刷新地图大本营判定让任意子弹击中基地立即游戏失败如果编译通过但运行闪退优先怀疑两件事构造函数里某个指针没有初始化或者地图数组下标越界。排查时可以临时在tick()开头打印日志定位崩溃发生在那一次调用上。6. 从大作业到作品双人模式、关卡外置与QML重写的进阶方向这份代码如果只是交上去已经能拿到不错的分数但如果你想让它成为简历上的作品还有三个低成本的升级方向。6.1 把单人改成双人玩家数组与第二套按键原项目定位是单机单人所以只有一辆玩家坦克。改成双人最直接的办法是把Game里的player指针换成QVector两个玩家各持一套按键映射和一个出生点。玩家1继续用WASD和J玩家2可以映射方向键加回车键。碰撞逻辑基本不用动只要在子弹归属上多一个playerId字段防止玩家2的子弹误伤玩家1就行。6.2 关卡外置与QML界面让35关变成配置文件把地图数组从代码里挪到一个文本文件每行13个数字加载时按行读入这样改关不需要重新编译。如果你想向Qt Quick方向走常见的思路是把Tank、Bullet、Field这些纯逻辑类保留在C侧绘制层换成QML的Canvas或者PaintedItem坐标和状态通过信号槽同步——这就是一个很标准的界面与逻辑分层面试时能讲的东西就多了一层。从那以后我每次拿到一份大作业源码都会先花十分钟把Qt版本、编译器套件、构建目录三者对齐再动代码。这份坦克大战我自己跑了两遍踩得最深的坑是地图格子尺寸和碰撞检测里的cellSize没对上第二深的坑是绘图写在了paintEvent外面。答案都在上面了。希望帮到你。本文还有配套的精品资源点击获取