1. 项目概述与核心价值

最近在整理旧项目时,翻出了几年前用 Qt C++ 写的一个俄罗斯方块。当时写它,纯粹是为了巩固自己对 Qt 图形视图框架和 C++ 面向对象设计的理解,没想到后来成了我面试新人、给团队做代码规范讲解的经典案例。这个项目麻雀虽小,五脏俱全,从底层的数据结构设计、游戏逻辑实现,到上层的界面交互、动画效果,再到代码的可维护性和扩展性思考,几乎涵盖了桌面应用开发中你会遇到的大部分核心问题。

如果你是一名 C++ 开发者,想深入理解如何将算法和数据结构应用到具体的图形程序中;或者你是一名 Qt 初学者,厌倦了书本上的按钮和文本框,想动手做一个有挑战性、有成就感的项目;亦或是你希望学习如何设计一个清晰、健壮、易于扩展的软件架构,那么这个俄罗斯方块的实现过程,会给你带来远超一个简单游戏本身的收获。它不仅仅是一个“方块下落消除”的游戏,更是一个完整的、工业级的桌面应用程序开发实践。

2. 整体架构与设计思路拆解

2.1 为什么选择 Qt 和 C++?

首先得聊聊技术选型。用 C++ 写游戏逻辑是顺理成章的,因为俄罗斯方块的核心是状态管理和碰撞检测,这些对性能有轻微要求但更强调精确控制的逻辑,用 C++ 这种“零成本抽象”的语言来实现非常合适。你可以精细地控制每一块内存,设计高效的数据结构(比如用位运算表示方块形状),这对于理解计算机底层如何工作大有裨益。

而选择 Qt 作为 GUI 框架,则是因为它提供了强大的 QGraphicsView 架构。传统的游戏开发可能会直接操作像素或使用游戏引擎,但对于这样一个2D网格游戏, QGraphicsView 堪称“降维打击”。它将游戏场景 ( QGraphicsScene ) 和游戏中的每个元素 ( QGraphicsItem ) 对象化,你只需要关心每个方块的逻辑状态和外观,至于渲染、刷新、坐标变换这些脏活累活,Qt 都帮你处理好了。这让我们能把主要精力放在游戏本身的逻辑上,而不是陷在图形 API 的细节里。

2.2 核心模块划分

一个健壮的俄罗斯方块程序,绝不能把所有代码都塞进 main.cpp 。我将其拆分为以下几个核心模块,这也是一个良好软件设计的起点:

  1. 游戏核心引擎 (GameEngine) :这是游戏的大脑。它不关心界面长什么样,只负责维护游戏状态。包括:

    • 游戏板 (Board) :一个二维数组(或其它数据结构),记录每个网格位置是否有方块以及方块的颜色。
    • 当前方块 (CurrentTetromino) 下一个方块 (NextTetromino) :保存方块类型、旋转状态、在板上的位置。
    • 游戏逻辑 :处理方块的移动、旋转、下落、锁定、行消除判定、分数计算、等级提升等。
    • 状态管理 :游戏是处于运行、暂停、结束还是开始菜单状态。
  2. 图形表示层 (Widgets/Views) :这是游戏的脸面,基于 Qt 实现。

    • 主游戏视图 (GameView) :继承自 QGraphicsView ,用于显示游戏板。它负责将 GameEngine 中的 Board 数据状态,转换为屏幕上一个个有颜色的 QGraphicsRectItem
    • 下一个方块预览视图 (NextBlockView) :一个小型的 QGraphicsView ,用于预览下一个即将出现的方块。
    • 控制面板 (ControlPanel) :包含开始、暂停、重置按钮,以及显示分数、等级、已消除行数的标签 ( QLabel )。
  3. 方块数据与逻辑 (Tetromino) :这是游戏的血肉。我将七种不同形状的方块(I, J, L, O, S, T, Z)抽象成一个 Tetromino 类。这个类的设计很有讲究:

    • 数据表示 :通常用一个 4x4 的布尔矩阵或整数矩阵来表示一个方块在某个旋转状态下的形状。我更喜欢用预定义的、硬编码的数组来存储所有形状的所有旋转状态,因为俄罗斯方块的形状是固定且有限的,这样效率最高。
    • 行为 :提供查询形状、旋转、获取边界等方法。它本身不持有位置信息,位置信息由 GameEngine 管理。
  4. 定时器与输入处理 :这是游戏的神经系统。

    • 游戏循环 :传统的游戏循环 ( while(running) { update(); render(); } ) 在事件驱动的 Qt 中并不直接。我们使用 QTimer 来模拟。一个定时器以固定的时间间隔(随等级加快)触发,驱动方块自动下落 ( gameUpdate )。这是游戏的主时钟。
    • 键盘事件 :重写主窗口的 keyPressEvent 函数,捕获方向键(左、右、下、上/旋转)、空格键(硬降)等,并调用 GameEngine 的相应方法。

这样的分层架构(引擎-视图-数据)带来了巨大的好处: GameEngine 可以独立进行单元测试;如果你想换一个 GUI 框架(比如用控制台字符画来显示),只需要重写视图层,核心逻辑几乎不用动。这就是关注点分离的力量。

3. 核心细节解析与实现要点

3.1 游戏板 (Board) 的数据结构选择

游戏板本质上是一个 WIDTH x HEIGHT 的网格。最直观的是使用二维数组,例如 std::array<std::array<CellState, WIDTH>, HEIGHT> ,其中 CellState 可以是一个枚举(空、有方块)或直接存储颜色值。

但我这里分享一个更高效、也更“C++”的技巧:使用一维数组 std::vector<int> ,并通过索引计算来模拟二维访问。例如,位置 (x, y) 对应索引 y * WIDTH + x 。这样做的好处是内存连续,缓存友好,并且在某些操作(如检查一行是否满)时,可以按行进行快速遍历。 vector 也方便我们动态调整大小(虽然俄罗斯方块不需要),以及使用 std::fill 等算法。

class Board {
public:
    static const int WIDTH = 10;
    static const int HEIGHT = 20;

    Board() : cells(WIDTH * HEIGHT, 0) {} // 0 表示空

    // 检查位置是否为空或是否在边界内
    bool isEmpty(int x, int y) const {
        if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) {
            return false; // 边界外视为“非空”,用于碰撞检测
        }
        return cells[y * WIDTH + x] == 0;
    }

    void setCell(int x, int y, int color) { cells[y * WIDTH + x] = color; }
    int getCell(int x, int y) const { return cells[y * WIDTH + x]; }

    // ... 其他方法,如消除行
private:
    std::vector<int> cells; // 存储颜色值,0为空
};

注意 :在 isEmpty 函数中,对于超出边界的坐标直接返回 false ,这是一个关键设计。这简化了方块移动和旋转时的碰撞检测逻辑——我们只需要检查方块每个部分的目标位置是否 isEmpty ,如果越界,自然就碰撞了。

3.2 方块 (Tetromino) 的旋转与碰撞检测

方块的旋转是俄罗斯方块算法的精髓。我采用经典的“超级旋转系统”简化版。为每种方块预定义其四个旋转状态(0°, 90°, 180°, 270°)的形状数据。

class Tetromino {
public:
    enum Type { I, J, L, O, S, T, Z };

    // 获取某个类型、某个旋转状态下的方块形状数据
    static const std::array<std::array<int, 4>, 4>& getShape(Type type, int rotation);

    // 获取方块的初始生成位置(通常让方块在顶部中间出现)
    static QPoint getSpawnOffset(Type type);
};

形状数据可以用一个 4x4 的矩阵表示,1 代表有方块,0 代表无。例如,T 型方块在 0 度旋转时可能是:

{0, 1, 0, 0},
{1, 1, 1, 0},
{0, 0, 0, 0},
{0, 0, 0, 0}

碰撞检测 发生在方块尝试移动或旋转时。逻辑非常直接:

  1. 对于当前方块,根据其类型、旋转状态和当前位置,计算出其四个方块单元在游戏板上的坐标。
  2. 对于目标动作(如向左移动),先计算出移动后的新坐标。
  3. 遍历这四个新坐标,调用 board.isEmpty(x, y) 进行检查。
  4. 如果所有位置都为空,则动作有效;否则,发生碰撞。

对于旋转,需要先根据当前旋转状态计算下一个状态的数据,然后检查旋转后的新形状是否会与棋盘上的已有方块或边界碰撞。如果碰撞,则不允许旋转(在经典规则中,有时会尝试“踢墙”,即旋转后如果碰撞,允许将方块向左/右微移一格再尝试,这是一个进阶话题)。

3.3 行消除与分数计算

当一行中所有格子都被填满时,这一行就需要被消除。算法步骤如下:

  1. 从棋盘底部( y = HEIGHT - 1 )开始向上遍历每一行。
  2. 检查该行是否所有格子都非空( !isEmpty )。
  3. 如果是,标记该行待消除。
  4. 消除后,上方所有行需要逐行下落。最经典的方法是:
    • 准备一个写指针 writeY ,从底部开始。
    • 从底部向上遍历一个读指针 readY
    • 如果 readY 行不是满行,则将其复制到 writeY 行,然后 writeY--
    • 如果 readY 行是满行,则跳过它(不复制), readY 继续向上, writeY 不动。
    • 遍历结束后, writeY 以上的所有行(顶部)都应该被清空。
int Board::clearFullLines() {
    int linesCleared = 0;
    int writeY = HEIGHT - 1;

    for (int readY = HEIGHT - 1; readY >= 0; --readY) {
        bool lineFull = true;
        for (int x = 0; x < WIDTH; ++x) {
            if (isEmpty(x, readY)) {
                lineFull = false;
                break;
            }
        }

        if (!lineFull) {
            // 将非满行复制到写指针位置
            if (writeY != readY) {
                for (int x = 0; x < WIDTH; ++x) {
                    setCell(x, writeY, getCell(x, readY));
                }
            }
            writeY--;
        } else {
            linesCleared++;
        }
    }

    // 清空顶部剩余的行(writeY 以上的行)
    for (int y = 0; y <= writeY; ++y) {
        for (int x = 0; x < WIDTH; ++x) {
            setCell(x, y, 0);
        }
    }

    return linesCleared;
}

分数计算 通常与一次消除的行数有关,并呈指数增长,以鼓励玩家一次性消除多行(如 Tetris,消除四行)。一个简单的公式可以是: 单行:100分, 双行:300分, 三行:500分, 四行:800分 。总分还会随着等级提升而倍增。等级则根据累计消除的行数来提升(例如每消除10行升一级),等级的提升会缩短方块自动下落的时间间隔,增加游戏难度。

4. 基于 Qt 的图形界面实现详解

4.1 使用 QGraphicsView 构建游戏场景

QGraphicsView QGraphicsScene QGraphicsItem 是 Qt 图形视图框架的三驾马车,用来实现这个游戏界面再合适不过。

  1. 创建场景 (Scene) QGraphicsScene 是一个容器,管理所有的图形项。我们的游戏板、当前方块、已固定的方块,都是这个场景里的项。

    QGraphicsScene *gameScene = new QGraphicsScene(this);
    gameScene->setSceneRect(0, 0, BOARD_PIXEL_WIDTH, BOARD_PIXEL_HEIGHT); // 设置场景逻辑大小
    ui->graphicsView->setScene(gameScene); // graphicsView 是UI设计器拖进来的
    ui->graphicsView->setRenderHint(QPainter::Antialiasing); // 抗锯齿
    ui->graphicsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 隐藏滚动条
    ui->graphicsView->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
    
  2. 绘制网格背景 :为了美观和定位,可以先在场景上画一个网格背景。这可以通过创建一个自定义的 QGraphicsRectItem 并重写其 paint 方法来实现,或者更简单点,在场景中添加一系列线条。

    // 绘制垂直和水平网格线
    QPen gridPen(Qt::gray, 1, Qt::DotLine);
    for (int x = 0; x <= Board::WIDTH; ++x) {
        gameScene->addLine(x * CELL_SIZE, 0, x * CELL_SIZE, Board::HEIGHT * CELL_SIZE, gridPen);
    }
    for (int y = 0; y <= Board::HEIGHT; ++y) {
        gameScene->addLine(0, y * CELL_SIZE, Board::WIDTH * CELL_SIZE, y * CELL_SIZE, gridPen);
    }
    
  3. 表示方块单元 :游戏中的每个小方块(无论是当前下落的,还是已经固定在底部的)都是一个 QGraphicsRectItem 。我们可以为其设置颜色、边框等属性。

    QGraphicsRectItem* cellItem = new QGraphicsRectItem(0, 0, CELL_SIZE, CELL_SIZE);
    cellItem->setPos(x * CELL_SIZE, y * CELL_SIZE); // 关键:将逻辑坐标转换为场景坐标
    cellItem->setBrush(QBrush(color)); // 设置填充色
    cellItem->setPen(QPen(Qt::black, 1)); // 设置黑色边框
    gameScene->addItem(cellItem);
    

    这里有一个 关键技巧 :我们不应该频繁地创建和删除 QGraphicsRectItem (这很耗时)。更好的做法是“对象池”或“复用”。例如,为游戏板的每个网格预先创建一个 QGraphicsRectItem 并隐藏( setVisible(false) ),当某个网格有方块时,就显示它并设置颜色;当方块消除或移动时,就隐藏或改变对应项的状态。对于当前下落的方块,由于其位置频繁变化,可以单独管理几个 QGraphicsRectItem 并更新其 setPos

4.2 游戏状态同步与渲染

游戏的核心循环由 QTimer 驱动。我们需要将 GameEngine 的状态同步到 QGraphicsScene

  1. 定时器驱动更新

    // 在构造函数或开始游戏时
    QTimer *gameTimer = new QTimer(this);
    connect(gameTimer, &QTimer::timeout, this, &MainWindow::gameUpdate);
    gameTimer->start(500); // 初始500毫秒下落一次
    
  2. 更新函数 gameUpdate

    void MainWindow::gameUpdate() {
        if (!gameEngine.isPaused()) {
            bool moved = gameEngine.moveCurrentPieceDown(); // 尝试下落一格
            if (!moved) {
                // 如果无法下落,说明方块到底了,将其固定到棋盘
                gameEngine.lockCurrentPiece();
                int lines = gameEngine.clearFullLines(); // 消除满行并计分
                updateScore(lines);
                // 生成新的当前方块
                if (!gameEngine.spawnNewPiece()) {
                    // 如果新方块无法生成,游戏结束
                    gameOver();
                }
            }
            // 无论是否移动,都需要刷新视图
            refreshGameView();
        }
    }
    
  3. 刷新视图 refreshGameView :这是连接引擎和视图的桥梁。它的职责是遍历游戏板 ( Board ) 的每个单元格,确保场景中对应的图形项显示正确的颜色(或隐藏)。对于当前下落的方块,则根据其位置和形状,更新对应的图形项位置。

    • 优化点 :不要每次都清空场景重画。应该维护一个二维的 QGraphicsRectItem* 数组(或 vector ),与游戏板的网格一一对应。在 refreshGameView 中,只更新那些状态发生变化的网格对应的图形项。这能显著提升性能。

4.3 键盘事件处理与用户交互

为了让游戏响应键盘,我们需要重写主窗口的 keyPressEvent

void MainWindow::keyPressEvent(QKeyEvent *event) {
    if (gameEngine.isGameOver()) return;

    switch (event->key()) {
        case Qt::Key_Left:
            gameEngine.moveCurrentPieceLeft();
            refreshGameView();
            break;
        case Qt::Key_Right:
            gameEngine.moveCurrentPieceRight();
            refreshGameView();
            break;
        case Qt::Key_Down:
            gameEngine.moveCurrentPieceDown();
            refreshGameView();
            break;
        case Qt::Key_Up:
            gameEngine.rotateCurrentPiece();
            refreshGameView();
            break;
        case Qt::Key_Space:
            // 硬降:一直下落直到锁定
            while (gameEngine.moveCurrentPieceDown()) {
                // 可以在这里加入快速下落的视觉反馈,比如短暂延迟
            }
            gameEngine.lockCurrentPiece();
            // ... 处理消除和生成新方块
            refreshGameView();
            break;
        case Qt::Key_P:
            gameEngine.togglePause();
            // 更新按钮文字或状态提示
            break;
        default:
            QMainWindow::keyPressEvent(event);
    }
}

实操心得 :处理键盘事件时,要注意“自动重复”。默认情况下,按住一个键, keyPressEvent 会被连续触发。对于俄罗斯方块,我们可能希望左/右键是步进式的(按一次动一格),而不是按住就连续滑行。可以通过判断 event->isAutoRepeat() 来忽略自动重复事件,或者自己实现一个更复杂的输入缓冲和计时系统来控制重复速率。简单起见,可以先忽略自动重复,这样体验更可控。

5. 进阶优化与功能扩展

一个基础版本完成后,可以考虑以下优化和扩展,这能让你的项目从“作业级”提升到“作品级”。

5.1 动画效果:消除行动画与软降影子

  • 行消除动画 :当一行被消除时,直接让方块消失有点生硬。可以添加一个渐隐或爆炸的小动画。实现方法可以是:在消除行时,不立即从场景中移除对应的图形项,而是启动一个 QPropertyAnimation ,在几百毫秒内将这些项的透明度 ( opacity ) 从1渐变到0,动画结束后再真正移除并更新棋盘状态。
  • 影子 (Ghost Piece) :显示当前方块如果直接硬降会落在哪里。这能极大提升游戏体验。实现方法:在 refreshGameView 中,除了绘制当前方块,再计算一次“硬降最终位置”。计算就是模拟一直调用 moveCurrentPieceDown 直到碰撞。然后,在这个最终位置,用半透明(例如 QColor(255,255,255,50) )的方块绘制一个“影子”。注意影子不要干扰正常的碰撞检测逻辑。

5.2 游戏状态持久化与设置

  • 保存最高分 :使用 QSettings 可以非常方便地将最高分保存到系统注册表(Windows)或 plist 文件(macOS)中。
    QSettings settings(\"MyCompany\", \"TetrisQt\");
    int highScore = settings.value(\"highScore\", 0).toInt();
    if (currentScore > highScore) {
        settings.setValue(\"highScore\", currentScore);
    }
    
  • 游戏设置 :可以创建一个设置对话框,让用户调整方块颜色、网格是否显示、下落初始速度、控制键位等。这些设置同样可以用 QSettings 保存。

5.3 代码质量与架构优化

  • 信号与槽解耦 :不要让 MainWindow 知道太多 GameEngine 的内部细节。可以让 GameEngine 在状态改变时(如分数变化、行消除、游戏结束)发射信号 ( signals ),而 MainWindow 连接这些信号来更新UI。这样两者耦合度更低。
    // 在 GameEngine 中定义信号
    signals:
        void scoreChanged(int newScore);
        void linesCleared(int count);
        void gameOver();
    
    // 在 MainWindow 中连接
    connect(&gameEngine, &GameEngine::scoreChanged, ui->scoreLabel, &QLabel::setNum);
    connect(&gameEngine, &GameEngine::gameOver, this, &MainWindow::onGameOver);
    
  • 资源管理 :将方块的颜色、形状定义等常量集中到配置文件中,而不是硬编码在代码里。这样未来换皮肤会很容易。
  • 单元测试 :为 GameEngine Board Tetromino 等核心逻辑类编写单元测试(使用 Google Test 等框架)。确保行消除、碰撞检测、旋转等逻辑绝对正确。

6. 常见问题与调试技巧实录

在开发过程中,我踩过不少坑,这里记录几个典型问题和解决方法。

6.1 方块旋转时“卡墙”或位置偏移

问题描述 :方块在靠近边界旋转时,有时会卡进墙里,或者旋转后位置发生不希望的偏移。 根本原因 :旋转算法的原点(旋转中心)定义不准确,或者碰撞检测在旋转后没有正确计算方块的新边界。 解决方案

  1. 统一原点 :确保每种方块的形状数据都以其“视觉中心”或标准原点进行定义。对于俄罗斯方块,通常旋转中心是形状矩阵的某个特定点(对于大多数方块,是中间偏上的位置)。查阅“俄罗斯方块旋转系统”资料,确保你的形状数据符合标准。
  2. 精确碰撞检测 :旋转时,先根据当前状态和旋转方向,计算出旋转后的形状数据。然后,遍历新形状的每一个方块单元,计算其在棋盘上的 绝对坐标 (当前方块位置 + 该单元在形状中的偏移量),再进行碰撞检测。务必使用这个计算后的绝对坐标,而不是相对坐标。
  3. 实现“踢墙” :在标准规则中,如果旋转后发生碰撞,系统会尝试将方块向左、右、上等方向微移一格,看是否能成功旋转。这是一个固定的、与方块类型和旋转状态相关的偏移量表。实现这个能极大提升操作手感。

6.2 图形闪烁或刷新效率低下

问题描述 :游戏运行时画面闪烁,或者在高刷新率下CPU占用率偏高。 根本原因 :频繁地清空整个场景 ( scene->clear() ) 然后重画所有项。 解决方案

  1. 项复用 :如前所述,为棋盘每个格子创建永久的 QGraphicsRectItem 并存入一个二维数组。更新时只修改其颜色和可见性,绝不删除重建。
  2. 局部更新 :对于当前下落的方块,使用4个(或更少,取决于形状)独立的 QGraphicsRectItem 。每次移动时,只调用这4个项的 setPos() 来更新位置,而不是删除再添加。
  3. 双缓冲 QGraphicsView 默认是开启双缓冲的 ( setViewportUpdateMode(QGraphicsView::FullViewportUpdate) SmartViewportUpdate )。确保你没有错误地关闭它。对于静态背景(如网格),可以将其设置为 ItemDoesntPropagateOpacityToChildren 等标志,优化渲染。

6.3 键盘响应不灵敏或冲突

问题描述 :快速连续按键时,有些操作被吞掉;或者按着下键加速下落时,同时按左右键没反应。 根本原因 :Qt 的事件处理是同步的。如果在一个 keyPressEvent 中进行了大量计算(比如硬降的循环),会阻塞事件循环,导致后续按键无法及时响应。 解决方案

  1. 避免长时操作阻塞事件循环 :像硬降这种操作,不应该用 while 循环一次性完成。可以改为:按下空格时,标记一个“硬降状态”,然后在 gameUpdate 定时器回调中,以比正常下落快得多的速度(比如每帧下落多格)来处理,直到锁定。这样事件循环就不会被阻塞。
  2. 使用键盘状态跟踪 :对于方向键,我们可以不只在 keyPressEvent 中响应,也在 keyReleaseEvent 中跟踪。维护一个 QSet<Qt::Key> 来记录当前哪些键被按住。在 gameUpdate 中,根据被按住的键来执行连续移动(比如左键按住,每过N帧向左移动一格)。这能实现更流畅的连续移动和多个按键的组合。

6.4 内存泄漏排查

问题描述 :长时间运行游戏后,内存缓慢增长。 排查技巧

  1. 使用 Qt 的父子对象机制 :在创建 QObject 派生类(包括 QGraphicsItem )的对象时,尽量指定父对象。当父对象被销毁时,Qt 会自动销毁其所有子对象。例如 new QGraphicsRectItem(parentItem)
  2. 仔细管理动态创建的项 :如果你必须手动管理生命周期(比如对象池),确保在适当的时候(如游戏重置、窗口关闭)正确释放内存。
  3. 利用工具 :在 Linux/macOS 下可以使用 valgrind ,在 Windows 下可以使用 Visual Studio 的诊断工具来检测内存泄漏。对于 Qt 项目,在程序退出时,如果控制台输出还有 QObject 未被删除的警告,就需要仔细检查。

开发这个俄罗斯方块的过程,是一个不断迭代和重构的过程。从最初一个能跑通的“大泥球”代码,到后来清晰分层的架构,再到加入各种优化和特效,每一步都加深了对 C++ 面向对象、Qt 框架以及游戏逻辑设计的理解。它像一把瑞士军刀,虽然小,但用到的技术和思考的问题非常全面。我强烈建议每一位想深入学习 Qt 或 C++ 应用开发的朋友,都亲手实现一遍,你会遇到这里提到的所有问题,并找到属于自己的解决方案。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐