Qt 框架下小球打砖块游戏的设计与实现:从零构建一个高性能、可扩展的 2D 游戏引擎
'小球打砖块'是一款使用 C++ 语言和 Qt 框架开发的经典休闲游戏,支持跨平台运行,涵盖图形界面构建、事件处理、碰撞检测与游戏逻辑实现等核心技术。通过控制挡板反弹小球以消除砖块,玩家需完成关卡目标。本项目全面整合 GUI 设计、动画控制、状态管理与资源处理,适用于学习 Qt 应用开发与游戏编程基础。
如何使用 Qt 框架和 C++ 语言开发一款经典的小球打砖块游戏。文章涵盖了从架构设计(三层模式)、视觉系统(QGraphicsView 与 Scene-View-Item 模型)、游戏主循环驱动、图形元素封装(QGraphicsItem 继承)、图像资源加载与优化、动态场景更新技巧、键盘事件处理(防抖与状态管理)、小球运动与反射算法、碰撞检测实战以及游戏状态机设计等核心内容。通过完整的代码示例和流程图,帮助开发者掌握从界面绘制到逻辑封装的完整流程,构建高性能、可扩展的 2D 游戏引擎。
'小球打砖块'是一款使用 C++ 语言和 Qt 框架开发的经典休闲游戏,支持跨平台运行,涵盖图形界面构建、事件处理、碰撞检测与游戏逻辑实现等核心技术。通过控制挡板反弹小球以消除砖块,玩家需完成关卡目标。本项目全面整合 GUI 设计、动画控制、状态管理与资源处理,适用于学习 Qt 应用开发与游戏编程基础。
如果把所有逻辑都塞进一个类里,比如 MainWindow,那很快就会变成一团乱麻:绘图、移动、碰撞、音效全都混在一起。所以,我们采用经典的三层架构模式:
这三者之间通过信号槽机制解耦通信。Qt 的 QObject 派生类天然支持信号槽,这是实现松耦合的最佳武器。
QGraphicsView 就像是为复杂交互式应用量身定做的舞台导演。它与 QWidget + QPainter 的对比如下:
| 特性 | QWidget + QPainter | QGraphicsView |
|---|---|---|
| 图元管理 | 手动维护列表 | 内置场景自动管理 |
| 局部刷新 | 全局重绘或手动裁剪 | 自动脏区域检测 |
| 动画支持 | 需配合 QTimer 自行实现 | 支持 QPropertyAnimation 等高级动画 |
| 坐标系统 | 设备坐标为主 | 场景 + 视图 + 项目三级坐标体系 |
| 事件分发 | 手动判断点击位置 | 自动映射并转发给对应 item |
双缓冲机制默认开启,彻底告别闪烁问题。
简单说就是三个角色:
graph TD
A[QWidget 主窗口] --> B[QVBoxLayout 布局]
B --> C[QGraphicsView 视图]
C --> D[QGraphicsScene 场景]
D --> E[QGraphicsItem 砖块]
D --> F[QGraphicsItem 小球]
D --> G[QGraphicsItem 挡板]
从主窗口到最底层的图形项,层层嵌套,职责分明。这种结构不仅清晰,还特别适合后期扩展。
在 Qt 中,我们靠 QTimer 来模拟这颗心脏。设置一个固定时间步长(通常是 16ms,对应 60FPS),让它不断触发更新:
connect(m_timer, &QTimer::timeout, this, &GameScene::gameLoop);
m_timer->start(16); // 每 16 毫秒跳一次
每一帧典型的闭环流程如下:输入处理 → 小球位移计算 → 碰撞检测 → 状态更新 → 场景重绘。最关键的一点是逻辑更新与渲染分离。即使某帧卡了一下,也不影响物理逻辑的准确性。
现在进入实战环节。我们要把挡板、小球、砖块这些元素一个个做出来,并且要做得优雅、可复用。
所有图形元素都应继承自 QGraphicsItem。你需要重写三个核心函数:
class Ball : public QGraphicsItem {
public:
QRectF boundingRect() const override { return QRectF(-5, -5, 10, 10); }
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override {
painter->setRenderHint(QPainter::Antialiasing);
painter->setBrush(Qt::yellow);
painter->setPen(Qt::darkGray);
painter->drawEllipse(-5, -5, 10, 10);
}
QPainterPath shape() const override {
QPainterPath path;
path.addEllipse(boundingRect());
return path;
}
};
为什么要返回 QPainterPath?因为默认的 collidesWithItem() 只用 boundingRect() 判断,那是矩形框。如果你的小球是圆的,就会出现明明没碰到却判定碰撞的情况。而一旦你重写了 shape(),Qt 会使用路径进行更精细的检测。
class Paddle : public QGraphicsItem {
QRectF boundingRect() const override {
return QRectF(-40, -8, 80, 16);
}
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override {
painter->setRenderHint(QPainter::Antialiasing);
painter->setBrush(QColor(70, 130, 180));
painter->setPen(Qt::NoPen);
painter->drawRoundedRect(boundingRect(), 8, 8);
}
};
class Brick : public QGraphicsItem {
Q_OBJECT
public:
Brick(qreal x, qreal y, const QColor &color) : m_color(color) {
setPos(x, y);
}
QRectF boundingRect() const override {
return QRectF(-35, -10, 70, 20);
}
void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override {
painter->setBrush(m_color);
painter->setPen(QPen(Qt::white, 1));
painter->drawRoundedRect(boundingRect(), 4, 4);
}
private:
QColor m_color;
};
| 属性 | Ball(小球) | Paddle(挡板) | Brick(砖块) |
|---|---|---|---|
| 形状 | 圆形 | 圆角矩形 | 圆角矩形 |
| 尺寸 | 直径 10px | 宽 80×高 16px | 宽 70×高 20px |
| 是否可动 | 是 | 是 | 否(销毁前固定) |
| 是否参与碰撞 | 是 | 是 | 是 |
| 是否可销毁 | 否 | 否 | 是 |
| Z 值(层级) | 中 | 高 | 低 |
Z 值决定了绘制顺序,数值越大越靠前。比如挡板一定要比砖块高,否则会被盖住。
纯色填充太单调?试试加个渐变效果吧!
void Ball::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) {
painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
QRadialGradient gradient(0, 0, 5, -2, -2);
gradient.setColorAt(0, Qt::white);
gradient.setColorAt(1, Qt::yellow);
painter->setBrush(gradient);
painter->setPen(QPen(Qt::darkGray, 0.8));
painter->drawEllipse(boundingRect());
}
性能提醒:抗锯齿虽然好看,但耗性能。建议只在关键元素上启用,或者让用户自行开关。
砖块通常以矩阵形式出现在屏幕上方。为了居中对齐,我们需要动态计算起始 X 坐标:
const int cols = 10;
const int brickWidth = 70;
const int hSpacing = 10;
const int totalWidth = cols * brickWidth + (cols - 1) * hSpacing;
const int startX = (800 - totalWidth) / 2; // 居中对齐
然后双重循环生成:
QColor colors[] = {Qt::red, Qt::magenta, Qt::blue, Qt::cyan, Qt::green};
for (int row = 0; row < 5; ++row) {
for (int col = 0; col < cols; ++col) {
qreal x = startX + col * (brickWidth + hSpacing);
qreal y = 50 + row * 30;
Brick *brick = new Brick(x, y, colors[row % 5]);
scene->addItem(brick);
m_bricks.append(brick);
}
}
这套算法灵活通用,改行列数也能完美适配。
静态绘图虽好,但真实游戏中常需加载背景图、精灵图等外部资源。
QPixmap background = QPixmap(":/images/bg_stars.png");
if (background.isNull()) {
qWarning() << "Failed to load background image!";
} else {
background = background.scaled(800, 600, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
scene->setBackgroundBrush(background);
}
几点注意:
: 开头,表示从 Qt 资源系统(qrc)读取,打包后无需额外文件;有些内容每次重绘代价很高,比如带阴影的文字提示。可以预先渲染成 QPixmap 缓存:
QPixmap cache(200, 50);
cache.fill(Qt::transparent);
QPainter p(&cache);
p.setRenderHint(QPainter::Antialiasing);
p.setFont(QFont("Arial", 16));
p.setPen(Qt::white);
p.drawText(cache.rect(), Qt::AlignCenter, "Press SPACE to Start");
p.end();
// 在 paint() 中直接绘制缓存图
painter->drawPixmap(-100, -25, cache);
这样就把多次矢量操作变成了单次位图拷贝,效率翻倍。
现在高清屏满地走,必须做好适配:
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
并在加载图像时设置像素比:
pixmap.setDevicePixelRatio(devicePixelRatioF());
否则在 Retina 屏上看起来会模糊不清。
游戏运行时,场景时刻在变:小球飞、挡板滑、砖块炸。如何高效更新?
当小球撞到砖块后,记得按顺序来:
void GameScene::destroyBrick(Brick *brick) {
removeItem(brick); // 先从场景移除
m_bricks.removeOne(brick); // 从容器删除引用
delete brick; // 最后再释放内存
emit brickDestroyed(); // 发信号通知得分更新
}
千万不要反过来!否则 delete 后再调 removeItem 会导致访问非法内存,程序崩溃分分钟的事。
避免在 paint() 中做字符串拼接、动态分配这类事:
// ❌ 错误示范:每帧新建 QString
void BadItem::paint(...) {
QString text = QString("Score: %1").arg(score);
painter->drawText(..., text);
}
// ✅ 正确做法:仅在 score 变化时更新缓存文本
void ScoreItem::updateScore(int newScore) {
if (newScore != m_score) {
m_score = newScore;
m_cachedText = QString("Score: %1").arg(m_score);
update(); // 触发重绘
}
}
设置合适的刷新模式:
view->setViewportUpdateMode(QGraphicsView::MinimalViewportUpdate);
这个模式只会重绘最小必要的区域,大幅降低 GPU 负载。结合 update() 的局部刷新能力,性能起飞。
再好的游戏逻辑,如果响应迟钝也白搭。我们来看看如何打造一套灵敏、稳定、防抖的输入系统。
操作系统自带的键盘连打机制(auto-repeat)延迟不定,不同平台还不一样。所以我们不能依赖 keyPressEvent 的频率来控制移动速度,而应该把它当作按下/释放的状态标志。
class GameView : public QGraphicsView {
QSet<int> m_pressedKeys;
protected:
void keyPressEvent(QKeyEvent *event) override {
if (!event->isAutoRepeat()) {
m_pressedKeys.insert(event->key());
}
QGraphicsView::keyPressEvent(event);
}
void keyReleaseEvent(QKeyEvent *event) override {
if (!event->isAutoRepeat()) {
m_pressedKeys.remove(event->key());
}
QGraphicsView::keyReleaseEvent(event);
}
};
然后在定时器里查询当前状态:
void GameScene::processInput() {
bool leftPressed = m_view->isKeyPressed(Qt::Key_Left);
bool rightPressed = m_view->isKeyPressed(Qt::Key_Right);
if (leftPressed && !rightPressed) {
m_paddle->moveLeft();
} else if (rightPressed && !leftPressed) {
m_paddle->moveRight();
}
}
这才是真正的连续移动体验。
Qt 的信号槽机制简直是模块解耦神器。我们可以定义语义化的信号:
class GameControl : public QObject {
Q_OBJECT
signals:
void moveLeft();
void moveRight();
void pauseGame();
void startGame();
};
在主窗口捕获按键并发射信号:
void MainWindow::keyPressEvent(QKeyEvent *event) {
switch (event->key()) {
case Qt::Key_Left:
emit m_control->moveLeft();
break;
case Qt::Key_Right:
emit m_control->moveRight();
break;
case Qt::Key_Space:
emit m_control->pauseGame();
break;
default:
QMainWindow::keyPressEvent(event);
}
}
再绑定到具体行为:
connect(m_control, &GameControl::moveLeft, m_paddle, &Paddle::moveLeft);
connect(m_control, &GameControl::moveRight, m_paddle, &Paddle::moveRight);
connect(m_control, &GameControl::pauseGame, this, &GameScene::togglePause);
好处是什么?将来换成手柄、触控甚至语音控制,只要发射同样的信号就行,完全不用改其他代码。
下面是各组件的角色分工:
classDiagram
class MainWindow {
+keyPressEvent()
-emit moveLeft()
-emit pauseGame()
}
class GameControl {
<<signal>>
+moveLeft()
+moveRight()
+pauseGame()
}
class GameScene {
+togglePause()
+startGame()
}
class Paddle {
+moveLeft()
+moveRight()
}
MainWindow --> GameControl : emits signals
GameControl --> GameScene : connected to
GameControl --> Paddle : connected to
是不是有种事件总线的感觉?
高手之间的差距往往体现在细节上。下面我们来加几个高级特性。
你想不想让挡板有加速度?按久一点越来越快那种?
class Paddle : public QGraphicsItem {
qreal m_velocity = 0;
qreal m_acceleration = 20;
qreal m_maxSpeed = 300;
private slots:
void updateMovement() {
if (m_targetDir != 0) {
m_velocity += m_targetDir * m_acceleration / 60;
m_velocity = qBound(-m_maxSpeed, m_velocity, m_maxSpeed);
} else {
m_velocity *= 0.85; // 摩擦减速
}
setX(x() + m_velocity / 60);
checkBounds();
}
};
配上一个 16ms 的定时器,就能做出非常真实的物理感。
连续按 Space 很容易造成暂停→恢复→暂停的抖动。加个最小间隔即可:
void GameScene::togglePause() {
static QElapsedTimer lastToggle;
if (lastToggle.isValid() && lastToggle.elapsed() < 300) {
return; // 忽略 300ms 内的重复点击
}
lastToggle.restart();
m_paused = !m_paused;
if (m_paused) {
m_gameTimer->stop();
} else {
m_gameTimer->start();
}
}
游戏暂停时,当然不该让挡板还能动:
void GameScene::keyPressEvent(QKeyEvent *event) {
if (m_state == GameState::Paused || m_state == GameState::GameOver) {
if (event->key() == Qt::Key_Escape) {
emit exitRequested();
}
event->ignore(); // 其他键全部忽略
return;
}
// 正常处理...
}
终于到了最烧脑的部分——小球的运动轨迹和反弹逻辑。
我们为小球建个模型:
class Ball : public QGraphicsItem {
QPointF m_position;
QPointF m_velocity;
QPointF m_acceleration;
public:
void advance(int phase) override {
if (!phase) return;
m_position += m_velocity; // 简单欧拉积分
update();
}
void setVelocity(qreal vx, qreal vy) {
m_velocity = QPointF(vx, vy);
}
};
每帧调用 advance() 推进状态,简洁明了。
传统做法是判断碰哪边墙就反哪个分量。但这无法应对斜面或动态角度表面。真正的解法是使用矢量反射公式:
$$ \vec{v}' = \vec{v} - 2(\vec{v} \cdot \hat{n})\hat{n} $$
代码实现:
QPointF reflectVector(const QPointF &v, const QPointF &n) {
qreal dot = v.x()*n.x() + v.y()*n.y();
return v - 2 * dot * n.normalized();
}
只要传入单位法线向量,就能算出正确的新速度。无论是上下左右墙,还是任意倾斜面,通吃。
经典设定来了:打中挡板中间,球垂直向上;靠近边缘,则反弹角度更斜。
怎么做?构造一个伪法线!
QPointF calculatePaddleReflection(const Ball* ball, const Paddle* paddle) {
qreal relPos = (ball->pos().x() - paddle->centerX()) / (paddle->width() / 2);
QPointF fakeNormal(-relPos * 0.5, -1.0);
return reflectVector(ball->velocity(), fakeNormal.normalized());
}
relPos ∈ [-1, 1],越靠边,法线越倾斜,反射角也就越大。玩家瞬间就有我能控方向的错觉,游戏深度立马提升。
砖块是矩形,小球是圆形,怎么判断是否相撞?
思路是这样的:
bool checkBallBrickCollision(const QPointF& center, qreal radius, const QRectF& rect) {
qreal clampedX = qBound(rect.left(), center.x(), rect.right());
qreal clampedY = qBound(rect.top(), center.y(), rect.bottom());
qreal dx = center.x() - clampedX;
qreal dy = center.y() - clampedY;
return (dx*dx + dy*dy) < (radius*radius);
}
为了避免开方运算,我们比较的是距离平方,性能更高。
如果两个包围盒都不相交,就没必要算精确碰撞了:
QRectF ballRect = ball->sceneBoundingRect();
QRectF brickRect = brick->sceneBoundingRect();
if (!ballRect.intersects(brickRect)) continue;
这一招能过滤掉大部分无效检测,CPU 占用直降一半不止。
最后一步,把所有模块整合成一个完整的游戏。
我们定义四种状态:
enum GameState { Start, Playing, Paused, GameOver };
转换关系如下:
stateDiagram-v2
[*] --> Start
Start --> Playing: 用户点击开始
Playing --> Paused: 按下 P 键或菜单暂停
Paused --> Playing: 再次按下 P 键
Playing --> GameOver: 小球掉落底部三次
GameOver --> Start: 点击重新开始
state Playing {
[*] --> MovePaddle
[*] --> UpdateBall
[*] --> CheckCollisions
}
每个状态有不同的行为组合:
| 状态 | 定时器 | 输入响应 | UI 显示 |
|---|---|---|---|
| Start | 停 | 接受开始 | 显示 Press Start |
| Playing | 开 | 挡板控制 | 隐藏按钮,更新分数 |
| Paused | 停 | 仅恢复 | 显示 PAUSED 浮层 |
| GameOver | 停 | 重试 | 显示最终得分 |
切换函数示例:
void GameWidget::setState(GameState newState) {
currentState = newState;
switch (newState) {
case Start:
timer->stop();
startButton->show();
ball->resetPosition();
break;
case Playing:
timer->start(16);
startButton->hide();
break;
// ...
}
emit gameStateChanged(newState);
}
最终的主循环就像一台精密仪器:
connect(timer, &QTimer::timeout, [&]() {
if (currentState != Playing) return;
ball->move();
checkWallCollisions();
checkPaddleCollision();
handleCollisions();
checkWinCondition();
checkLoseCondition();
});
同时绑定 UI 响应状态变化:
connect(this, &GameWidget::gameStateChanged, [this](GameState s){ updateUiForState(s); });
整个系统形成了输入 → 状态 → 物理模拟 → 渲染 → 反馈的完美闭环。
我们从架构设计讲到图形绘制,从键盘响应讲到物理模拟,再到状态管理,一步步搭建出了一个结构清晰、性能优良、易于扩展的 2D 游戏骨架。
这不仅仅是一个小球打砖块游戏,它是你通往更复杂项目的跳板。未来你可以轻松加入:
记住一句话:优秀的设计让扩展变得简单,糟糕的设计让修改变得痛苦。
而现在,你手里握着的就是那份优秀的设计。继续加油,下一个爆款可能就是你做的。
编程不是写代码,而是创造世界。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online