C++ Qt坦克大战实战:从类设计到碰撞检测的完整实现
2026/9/14 2:55:19 网站建设 项目流程

简介:面向C++初学者的坦克大战游戏源码工程,基于Qt 5.14.1与C++编写,在Qt Creator 4.11.0中开发,完整实现经典坦克对战玩法。资源为可编译运行的Qt工程,共设置35个关卡,每关包含20个敌方坦克,玩家拥有3条生命,击败全部敌人自动进入下一关,通关35关即获胜;若生命数耗尽或大本营被击中则游戏失败。游戏采用单人模式,玩家通过W、S、A、D控制坦克移动,按J键发射子弹,敌方坦克由程序自动控制。压缩包整体约27.79MB,内容完整,可直接导入开发环境编译学习。已有1150人学习,适合作为C++课程设计、Qt入门练手或游戏编程参考项目;通过阅读源码可系统掌握面向对象设计、信号与槽机制、定时器驱动动画、碰撞检测及关卡切换等核心编程技巧。

1. 为什么把坦克大战做成C++大作业而不是用现成引擎

当年选大作业题目时,大概十个里有七个交飞机大战,剩下三个是俄罗斯方块。换坦克大战,不是因为怀旧,而是它正好卡在“纯C++能写完、又不至于简单到答辩被问穿”的位置上。用Qt写,意味着不碰Unity那套现成物理和渲染,地图、碰撞、敌人AI、关卡推进全靠自己用C++搭出来;而这恰恰是C++课程设计最想考察的东西:类怎么划分、资源怎么管理、事件怎么流转。这个项目适合正在做C++期末大作业、想把面向对象和Qt事件循环讲清楚的人。它不追求画面多炫,但每个模块都能拿出来被问细节。

2. 游戏对象建模:从坦克到子弹的类层次设计

2.1 为什么让所有游戏物件继承QObject

Qt自带的对象树(QObject parent-child)能解决游戏里最难堪的内存释放问题。坦克死了、子弹出界,只要调用deleteLater(),事件循环结束就会自动销毁,不需要手动维护一堆裸指针。另外,QObject还带了信号槽和QTimer,后面做敌方坦克生成、子弹碰撞通知都依靠它。

下面是我在项目里用的类关系表:

类名父类职责关键成员
GameObjectQObject所有游戏物体的公共基类QPoint pos; QSize size; bool alive;
TankGameObject玩家和敌人共用Direction dir; int hp; int speed; QTimer* fireTimer;
BulletGameObject子弹飞行与命中Direction dir; int speed; int damage;
WallGameObject砖墙/钢墙/水/树WallType type; bool destructible;
GameSceneQWidget主游戏窗口与事件入口QVector<GameObject*> objects;

这样分的好处是,后面碰撞检测只需要写一个函数接收两个GameObject*,然后按实际类型做分支。当然也可以把碰撞逻辑挂在基类里用虚函数,但那样每个子类都要写一遍,不如单独抽一个CollisionUtil。

2.2 Tank类的定义与移动逻辑

坦克是游戏里最核心的对象,我把它拆成“状态”和“行为”两部分。状态就是位置、方向、生命值;行为是移动、射击。注意不要把按键逻辑写进Tank,按键是界面层的事,Tank只提供move()和shoot()接口。

代码如下:

enum Direction { Up, Down, Left, Right }; class Tank : public GameObject { Q_OBJECT public: Tank(int x, int y, Direction dir, int hp, QObject* parent = nullptr) : GameObject(parent), m_pos(x, y), m_dir(dir), m_hp(hp) { m_speed = 2; // 每帧移动2像素,配合50ms定时器 m_fireCooldown = 0; // 冷却帧数 } void move(Direction dir, const QRect& bounds) { m_dir = dir; QPoint next = m_pos; switch (dir) { case Up: next.ry() -= m_speed; break; case Down: next.ry() += m_speed; break; case Left: next.rx() -= m_speed; break; case Right: next.rx() += m_speed; break; } // 边界约束,留出坦克自身宽度 if (bounds.contains(QRect(next, m_size))) { m_pos = next; } } void setDir(Direction dir) { m_dir = dir; } QPoint pos() const { return m_pos; } bool alive() const { return m_hp > 0; } private: QPoint m_pos; QSize m_size = QSize(32, 32); Direction m_dir; int m_hp; int m_speed; int m_fireCooldown; };

这段代码里的关键参数是m_speed=2和bounds.contains。speed像素值要和QTimer的间隔配合:如果定时器是50ms,那么一秒移动20次,一次2像素,也就是40像素/秒,大约0.8格/秒(每格32像素)。这个速度对坦克大战来说偏慢,实际可以调到3或4,但这时碰撞检测要把“穿透”问题考虑进去——详见第四章。注意bounds参数是游戏区域矩形,移动前先判断目标位置是否合法,可以避免坦克跑到地图外面去。

2.3 地图用二维数组比Tiled地图更省事

因为大作业不要求可视化编辑器,我直接用static数组声明关卡:

const int ROWS = 15, COLS = 15; int map[ROWS][COLS] = { {1,1,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,0,0,1}, {1,0,3,3,3,0,0,0,0,0,2,2,2,0,1}, // ... 简化,真实关卡有35张表 {1,0,0,0,0,0,0,0,0,0,0,0,0,0,1}, {1,1,1,1,1,1,0,0,0,1,1,1,1,1,1}, };

地图值含义:0空地、1砖墙(可打碎)、2钢墙(不可打碎)、3水(可阻挡)、4树(可穿透但遮挡)、5大本营。绘制时只需要在QPainter::drawImage里根据map[i][j]选择对应贴图。这里有个很容易踩的坑:地图索引和屏幕坐标是反的,map[row][col]对应屏幕(x=col32, y=row32),写转换函数时三番两次把row和col写反导致坦克走斜线。我一般封装一个toScreen(row,col)函数统一处理。

2.4 用QObject对象树管理游戏对象

场景里既有砖墙、钢墙这类静态物体,也有坦克、子弹这类动态物体。静态物体在关卡加载时创建后不会消失,动态物体频繁创建销毁。我选择把所有物体都挂到GameScene这个QWidget上,父对象是this,这样窗口关闭时所有子对象自动析构。

GameScene::GameScene(QWidget* parent) : QWidget(parent) { for (int r = 0; r < ROWS; ++r) { for (int c = 0; c < COLS; ++c) { if (map[r][c] == 1) { auto* wall = new Wall(c * 32, r * 32, Brick, this); m_objects.append(wall); } } } }

这里new出来的Wall父指针传this,一旦GameScene析构,对象树会递归delete所有子节点。但注意:如果某个子弹在碰撞后直接delete,而不是deleteLater,可能会在QTimer事件处理中触发“析构正在发送信号的对象”崩溃。所以动态物体建议统一用deleteLater(),稍后我会在第四章给出具体碰撞处理代码。

3. 事件驱动:键盘控制、定时刷新与子弹飞行

3.1 重写keyPressEvent是Qt小游戏最直接的做法

控制坦克移动有几种方案:QShortcut、事件过滤器、重写keyPressEvent。QShortcut适合按钮快捷键,事件过滤器适合要拦截全局按键的场景,而坦克大战只需要监控当前窗口,直接重写键盘事件最清晰,代码量也最少。

void GameScene::keyPressEvent(QKeyEvent* event) { switch (event->key()) { case Qt::Key_W: m_playerTank->setDir(Up); m_playerTank->move(Up, this->rect()); break; case Qt::Key_S: m_playerTank->setDir(Down); m_playerTank->move(Down, this->rect()); break; case Qt::Key_A: m_playerTank->setDir(Left); m_playerTank->move(Left, this->rect()); break; case Qt::Key_D: m_playerTank->setDir(Right); m_playerTank->move(Right, this->rect()); break; case Qt::Key_J: if (m_playerTank->canShoot()) { shootBullet(m_playerTank); } break; default: QWidget::keyPressEvent(event); } }

这里我故意没有用event->isAutoRepeat(),因为按住W连续移动是正常需求,坦克大战原版也是按住就持续走。但子弹不能按住J就连续射击,否则几秒就能清屏,所以要在Tank里加一个m_fireCooldown,每发射一次子弹就置为10帧,定时器每帧减1,只有为0才允许发射。这个冷却参数直接影响游戏难度,10帧配合50ms定时器是500ms一发,比较合理。

有个容易忽略的坑:QWidget默认不接受键盘焦点,必须在构造函数里setFocusPolicy(Qt::StrongFocus),否则keyPressEvent永远不会响应。我见过不少同学编译通过但按键盘没反应,八成是漏了这句。

3.2 用QTimer驱动游戏循环,别用while(1)

一开始有人会写while(true){ update(); },这在Qt里会让界面卡死,因为QPainter只能在事件循环中工作。正确做法是启动一个QTimer,让它每50ms触发一次update。

// GameScene构造函数里 m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, &GameScene::gameLoop); m_timer->start(50); // 20 FPS,对像素风坦克大战足够 void GameScene::gameLoop() { // 1. 更新玩家和敌人的冷却计时 // 2. 移动所有敌方坦克 // 3. 移动所有子弹 // 4. 检查碰撞 // 5. 检查通关/死亡条件 // 6. 调用update()触发paintEvent重绘 update(); }

定时器间隔50ms是常见的选择。间隔越小,动画越平滑,但碰撞穿透和CPU占用也越大。20FPS对于32×32像素的块状坦克,肉眼已经能接受。如果要调成更流畅的60FPS,就把start(16),同时把坦克speed从2改成1,避免一帧跨过一整格导致碰撞漏检。

3.3 子弹飞行与越界清理

子弹没有自己的线程,它在gameLoop里按方向线性移动。关键是要把子弹也当成GameObject存进m_objects,统一遍历,而不是单独维护一个list。

void GameScene::updateBullets() { for (auto* bullet : m_bullets) { switch (bullet->dir()) { case Up: bullet->moveBy(0, -bullet->speed()); break; case Down: bullet->moveBy(0, bullet->speed()); break; case Left: bullet->moveBy(-bullet->speed(), 0); break; case Right: bullet->moveBy( bullet->speed(), 0); break; } // 超出屏幕就标记死亡 if (!this->rect().contains(bullet->pos())) { bullet->setAlive(false); } } // 统一移除死亡子弹 for (auto it = m_bullets.begin(); it != m_bullets.end(); ) { if (!(*it)->alive()) { (*it)->deleteLater(); it = m_bullets.erase(it); } else { ++it; } } }

注意上面代码里的分类讨论:子弹飞行速度通常设为4或6,比坦克快,这样手感才爽。但速度越快,越容易在相邻两帧之间直接跳过一堵墙——如果墙是32像素宽,子弹速度大于32像素/帧就会穿透。所以speed上限建议不超过16。用erase加deleteLater可以防止QTimer回调中直接delete对象导致的悬垂指针。

3.4 让敌方坦克随机转向的简单AI

大作业不需要什么寻路,原版坦克大战的敌人也就是“碰到墙或边界就随机换方向”。我实现的方式是每帧尝试移动,如果移动失败(被墙挡住或到达边界)就随机换一个方向。

void EnemyTank::updateAI(const QRect& bounds) { int r = rand() % 3; // 0保持,1左转,2右转 if (r == 1) m_dir = Direction((m_dir + 3) % 4); if (r == 2) m_dir = Direction((m_dir + 1) % 4); QPoint oldPos = pos(); move(m_dir, bounds); if (pos() == oldPos) { // 没移动成功,强制转向 m_dir = Direction(rand() % 4); } // 每10帧随机开一枪 if (rand() % 10 == 0 && canShoot()) { emit shootRequested(m_dir); } }

这里用rand()%3来控制转向频率,rand()%10控制射击概率。这两个参数像麻将里的“手气”,调大敌人会更凶,调小就是散步。注意rand()要用srand(time(0))初始化,否则每次运行敌人行为都一样,答辩演示时容易被老师以为是写死。更稳的写法是用std::uniform_int_distribution,但大作业里rand()足以。

4. 碰撞检测与游戏规则状态机

4.1 AABB碰撞检测:为什么不用像素级

坦克大战所有物体都是矩形,用轴对齐包围盒(AABB)检测就够了。所谓AABB就是两个矩形在x轴和y轴的投影都重叠。Qt的QRect已经内置了intersects(),但为了能说清楚原理,我建议自己写一个,答辩时老师必问。

bool checkCollision(const QRect& a, const QRect& b) { if (a.right() < b.left() || b.right() < a.left()) return false; if (a.bottom() < b.top() || b.bottom() < a.top()) return false; return true; } // 在gameLoop里遍历所有动态物体 bool collided = false; for (GameObject* obj : m_objects) { if (obj == tank) continue; if (obj->alive() && checkCollision(tank->rect(), obj->rect())) { // 如果是墙:坦克退回上一帧位置 // 如果是敌人子弹:坦克扣血 // 如果是大本营:游戏失败 } }

AABB的缺点是不能处理旋转物体,但坦克大战里没有旋转,只有四个方向,矩形碰撞完全够用。碰撞后怎么处理取决于物体类型:坦克撞墙应该阻止移动;子弹撞墙应该销毁子弹并且如果是砖墙就消掉一块;子弹撞坦克则减少生命。注意坦克和子弹都移动的情况下,要先把两者都移动完,再统一检测,避免“我先移你后移”导致漏检。

4.2 子弹与地图的交互:砖墙扣除一个单位

砖墙在地图数组里是1,钢墙是2。子弹打中砖墙时,把对应的map值改成0,并删除那个Wall对象。这里有个细节:如果用数组值判断,那么一个格子只能整体消失;要做成原版那样“一块砖一格砖地碎”,需要把每块砖拆成4个小Wall。大作业里为了省事,我直接整格消失,答辩时可以补充说明这是简化版。

void GameScene::handleBulletHitWall(Bullet* bullet, Wall* wall, int row, int col) { if (wall->type() == Wall::Brick) { map[row][col] = 0; // 地图数据更新 wall->setAlive(false); // 碰撞对象标记 bullet->setAlive(false); // 同时播放一个简单特效:这里用setVisible(false) + 下一次重绘消失 } else if (wall->type() == Wall::Steel) { bullet->setAlive(false); // 钢墙不可破坏,子弹消失 } }

注意map[row][col]的下标顺序,row是行,col是列。因为墙是按row,col创建的,销毁时也应该用row,col去索引,而不是直接用像素坐标除以32。如果搞反了,你会发现子弹打的是左边墙,消失的却是右边墙,这是二维数组行列映射的典型错误。

4.3 关卡状态机:Playing、LevelClear、GameOver

游戏一共有35关,每关20个敌人,3条命。这些数据放在GameData里统一管理:

enum GameState { Playing, LevelClear, GameOver }; class GameData { public: int currentLevel = 1; int enemiesLeft = 20; // 当前关剩余敌人 int playerLives = 3; bool baseAlive = true; GameState state = Playing; bool isLevelClear() const { return enemiesLeft <= 0; } bool isGameOver() const { return playerLives <= 0 || !baseAlive; } };

每次敌人生成时enemiesLeft减1,敌人被击毁时也减1——注意可能在生成时减还是在死亡时减,原版是“本关敌人数”包括了还没出现的,为了让玩家有压力,我做成生成时减,意味着你杀光场上的敌人后,还可能有未被生成的敌人,需要等他们冒出来。这个规则要在游戏说明里写清楚,否则玩家会觉得“场上一只都没有却不通关”是bug。

4.4 通关与失败的处理流程

在gameLoop末尾,统一检查状态:

void GameScene::checkGameState() { if (m_data.isLevelClear()) { if (m_data.currentLevel >= 35) { m_data.state = GameOver; // 或者单独定义Victory QMessageBox::information(this, "胜利", "你通关了35关!"); close(); } else { m_data.currentLevel++; m_data.enemiesLeft = 20; loadLevel(m_data.currentLevel); } } else if (m_data.isGameOver()) { m_data.state = GameOver; QMessageBox::information(this, "失败", "坦克基地被摧毁!"); close(); } }

这个逻辑里有几个关键点。第一,关卡完成后要重置敌人数量并重新loadLevel,但不重置玩家生命。第二,loadLevel里要清空现有子弹和敌人,但不能清玩家坦克,否则玩家会觉得血亏。第三,35关作为胜利边界,需要单独定义GameState::Victory,否则和GameOver混在一起,弹窗文案会变得很奇怪。我上面代码里偷懒了,建议读者改成四个状态。

4.5 敌人刷新队列与生成点调度

35关不可能把20个敌人一次性放在地图上,那会堵死。原版是每关分成多波,在地图上固定生成点逐渐冒出。我用一个简单队列:

// 在loadLevel里填充生成队列 for (int i = 0; i < 20; ++i) { m_enemyQueue.enqueue(QPoint(0, 0)); // 三个生成点之一 } void GameScene::spawnEnemyIfNeeded() { // 场上敌人少于5且队列非空,且距离上次生成超过2秒 if (m_enemiesOnField.size() < 5 && !m_enemyQueue.isEmpty() && m_spawnTimer->remainingTime() == 0) { auto* enemy = createEnemy(m_enemyQueue.dequeue()); m_enemiesOnField.append(enemy); m_spawnTimer->start(2000); // 2秒生成间隔 } }

这里的“场上少于5个”是为了避免同屏敌人太多导致性能下降。生成间隔2000ms可以调,第一关可以短一点让玩家爽快,后期关可以加长制造紧张感。注意生成点要避开玩家出生位置,一共三个点:左上、右上、左下。玩家诞生在右下基地附近,四个点不能重叠,否则玩家一出生就被围殴。

5. 答辩加分项:对象池、局部重绘与配置外置

5.1 用对象池复用子弹,避免频繁new/delete

gameLoop里每50ms就可能有子弹创建销毁,而new和delete在堆上很慢,连续压力下帧率会波动。简单做法是预先创建30个子弹对象,初始setAlive(false),需要发射时从池里取一个活状态为false的子弹并初始化位置,打中或出界后再放回池子。

class BulletPool { public: BulletPool(int size, QObject* parent) { for (int i = 0; i < size; ++i) { m_pool.append(new Bullet(parent)); m_pool.last()->setAlive(false); } } Bullet* acquire(const QPoint& pos, Direction dir) { for (Bullet* b : m_pool) { if (!b->alive()) { b->setPos(pos); b->setDir(dir); b->setAlive(true); return b; } } return nullptr; // 池满了,表现为射速突然下降 } void release(Bullet* b) { b->setAlive(false); } private: QVector<Bullet*> m_pool; };

对象池的大小要根据关卡中的最大同屏子弹数来定,玩家+4个敌人,每个敌人最多同时5发子弹,最多25发。池子定成30就足够。如果acquire返回nullptr,说明池满了,可以放弃发射或强制回收最老的一颗。这个数据参数在答辩时讲出来会很加分,说明你考虑过资源边界。

5.2 用局部重绘替代整屏重绘

QWidget默认paintEvent会把整个窗口重新绘制一遍,如果地图上有很多砖墙和树,会造成不必要的开销。可以在gameLoop里记录变化区域,只更新包含移动物体和碰撞区域的那个矩形:

void GameScene::paintEvent(QPaintEvent* event) { Q_UNUSED(event); QPainter painter(this); // 绘制地图(这里可以只绘制event->rect()范围内的格子) for (int r = 0; r < ROWS; ++r) { for (int c = 0; c < COLS; ++c) { QRect cell(c * 32, r * 32, 32, 32); if (event->rect().intersects(cell)) { drawCell(&painter, r, c); } } } // 绘制物体 for (GameObject* obj : m_objects) { if (event->rect().intersects(obj->rect())) { obj->draw(&painter); } } }

但要注意:QWidget的paintEvent接收到的event->rect()是“需要更新的区域”,这个区域由update()和update(rect)来控制。如果你在gameLoop里调update()不带参数,整个窗口都会被标脏,局部重绘就失效了。正确写法是先计算所有移动物体的外接包围盒,再调用update(dirtyRect)。这个优化对坦克大战这样的小场景收益不明显,但体现了性能意识。

5.3 把35关的关卡数据抽到JSON

写35个二维数组放在代码里非常占篇幅,而且改关卡要重新编译。可以把每个关卡的地图、敌人数量、生成点抽到一个JSON文件,运行时用QJsonDocument读取。

{ "level": 1, "rows": 15, "cols": 15, "enemies": 20, "map": [ [1,1,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,0,0,1] ] }

加载时用QFile读取,再用QJsonDocument::fromJson解析。注意JSON里map是数组的数组,读取时要先取到QJsonArray,再遍历子数组。把关卡外置后,玩家自己改个map值就能生成自定义关卡,大作业演示时现场改文件再开游戏,效果比单纯讲代码更唬人。

5.4 答辩时这三个角落最容易让老师提问

第一个是碰撞穿透,你要能解释为什么子弹速度必须小于物体宽度。第二个是内存安全,你会不会出现子弹销毁后其他物体还持有其指针的情况,对象池和deleteLater怎么配合解决。第三个是关卡配置,如果第30关和第1关一样,是不是bug,关卡难度有没有递增曲线。这三个问题我都被问过,提前在代码里留好注释和上述设计,基本可以稳稳过关。

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

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

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

立即咨询