☰
C++大作业坦克大战:Qt实现35关完整游戏源码与碰撞检测、AI设计详解
2026/10/4 5:24:30 网站建设 项目流程

简介:这是一份基于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分别处理玩家输入和AI,Bullet独立管理飞行,GameField负责地图格子和阻挡判断。分工大致如下:

类/模块主要职责关键成员
Tank位置、方向、移动、存活状态x, y, dir, speed, alive
PlayerTank / EnemyTank玩家按键处理 / 敌方AIlives / homeX, fireProb
Bullet飞行、命中判定、销毁x, y, dir, speed, alive
GameField地图二维数组、阻挡判断mapData[][], row, col, cellSize
Game主控:定时器、关卡、胜负level, kills, gameState

Tank基类的头文件长这样:

// 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. 关卡系统与敌方AI:35关难度曲线和敌人自动控制

35关是这份项目最亮眼的设定。每关都是20个敌人、3条命,比例固定,难度靠什么递增?答案在地图布局和敌人参数上。这块拆开讲就是两件事:地图数据怎么存,敌人AI怎么控制。

4.1 关卡地图的数据结构:二维数组与格子坐标换算

地图是游戏的骨架。经典坦克大战的场地是13×13格,这份项目沿用这个规格很合理,窗口尺寸用13乘以cellSize(32)得到416像素,再留一点边给HUD。地图数据最常见的组织方式是二维数组,用数字代表地形:

// 第1关地图片段:0空地 1砖墙 2钢墙 3水域 // 13列 x 13行,cellSize=32 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)是关键兜底——敌人往前走撞墙后返回false,AI立刻随机换一个方向,这样即便某个出生点被墙围死,敌人也不会卡在原地不动。出生点一般固定三个位置,敌人刷新时随机选一个,避免所有敌人从同一个点挤出来。

还有一个所有同类项目都会踩的坑:场上敌人不能一次性全部刷新,否则玩家会被围在出生点。常见做法是场上最多同时存在4个敌人,每消灭一个,过一小段时间再补一个新敌人,直到本关20个全部出场。

4.3 35关的难度递进:参数表驱动而不是重写地图

35关如果全靠手绘地图,工作量会非常恐怖,而且容易让前后关卡难度跳跃。更省事的做法是用关卡号n推算出本关的敌人参数,地图只负责提供地形差异,难度增长交给数值。下面是一套典型的推算公式:

// 根据关卡号计算本关敌人的移动速度和开火概率 int enemySpeed = 2 + (n - 1) / 8; // 每8关增加1,35关时到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目录没有加入系统PATH,exe找不到动态链接库。

解决:把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 error,0 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外面。答案都在上面了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询