10、C++项目实战:俄罗斯方块游戏(控制台版)

俄罗斯方块,这个诞生于上世纪80年代的游戏,几乎是每个程序员的启蒙项目。说实话,我当年在大学里第一次用C语言写俄罗斯方块时,光是处理方块旋转就折腾了整整三天。现在回头看,这个项目确实是把C++基础语法、数据结构和算法串起来的最佳实践之一。

今天我们就来聊聊,怎么用C++在控制台里实现一个完整的俄罗斯方块。嗯,别小看控制台版,它麻雀虽小五脏俱全。

项目整体架构

先说说我的设计思路。俄罗斯方块的核心逻辑其实就三件事:方块的存储、方块的移动旋转、以及消行判定。我个人习惯把游戏拆成四个模块:

  • 游戏面板:一个二维数组,记录每个格子是否有方块
  • 当前方块:正在下落的活动方块,包含形状、位置、旋转状态
  • 输入处理:监听键盘按键,控制方块移动和旋转
  • 渲染引擎:把游戏状态输出到控制台

你想想看,这种分层设计的好处是什么?每个模块各司其职,改渲染逻辑不会影响游戏规则,改输入处理也不会破坏面板数据。我在实际项目中遇到过不少因为耦合太紧导致的bug,所以从一开始就养成模块化的习惯,能省下大量调试时间。

核心设计原则:游戏逻辑与界面渲染分离。控制台版如此,换成图形界面版也一样。

方块的表示与旋转

这是整个项目最巧妙的地方。俄罗斯方块有7种基本形状,每种形状有4个旋转状态。怎么用代码表示?

我推荐用4x4的矩阵来存储每个方块。为什么是4x4?因为所有方块(包括最长的I形)都能放进4x4的格子里。举个例子,T形方块可以这样定义:

// T形方块,用二维数组表示
// 1表示有方块,0表示空
int T_shape[4][4] = {
    {0, 1, 0, 0},
    {1, 1, 1, 0},
    {0, 0, 0, 0},
    {0, 0, 0, 0}
};

旋转怎么处理?说白了就是矩阵转置加镜像。我见过有人用三角函数算旋转,其实完全没必要。对于4x4矩阵,顺时针旋转90度的公式很简单:

// 顺时针旋转90度
// 新矩阵的(i, j)位置 = 原矩阵的(3-j, i)位置
void rotate(int shape[4][4]) {
    int temp[4][4] = {0};
    for (int i = 0; i < 4; i++) {
        for (int j = 0; j < 4; j++) {
            temp[i][j] = shape[3 - j][i];
        }
    }
    memcpy(shape, temp, sizeof(int) * 16);
}

小技巧:可以把7种方块的所有旋转状态都预计算好,存成一个常量表。运行时直接查表,比实时计算快得多。我早期版本就是实时计算,结果在低配机器上偶尔会卡顿。

碰撞检测

方块能不能左移?能不能旋转?能不能继续下落?这些都需要碰撞检测。逻辑其实很简单:

  1. 假设方块移动到了新位置
  2. 检查新位置是否超出左右边界或下边界
  3. 检查新位置是否与面板上已有的方块重叠
  4. 如果以上任意一条成立,则移动无效

代码实现大概长这样:

bool canMove(int newX, int newY, int shape[4][4]) {
    for (int i = 0; i < 4; i++) {
        for (int j = 0; j < 4; j++) {
            if (shape[i][j] == 0) continue;
            int boardX = newX + j;
            int boardY = newY + i;
            // 检查边界
            if (boardX < 0 || boardX >= COLS) return false;
            if (boardY >= ROWS) return false;
            // 检查是否与已有方块重叠
            if (boardY >= 0 && board[boardY][boardX] != 0) return false;
        }
    }
    return true;
}

这里有个细节要注意:方块在面板上方时,boardY可能是负数(方块还没完全进入面板)。所以检查面板数组时,要确保索引不小于0。我曾经在这里踩过坑,数组越界导致程序莫名其妙崩溃,查了半天才发现是负数索引的问题。

消行逻辑

消行是俄罗斯方块最爽的瞬间。逻辑也不复杂:

  • 从下往上扫描每一行
  • 如果某行所有格子都被填满,则消除该行
  • 消除后,上面的所有行整体下移一行
int clearLines() {
    int linesCleared = 0;
    for (int y = ROWS - 1; y >= 0; y--) {
        bool full = true;
        for (int x = 0; x < COLS; x++) {
            if (board[y][x] == 0) {
                full = false;
                break;
            }
        }
        if (full) {
            // 消除该行,上面的行下移
            for (int yy = y; yy > 0; yy--) {
                memcpy(board[yy], board[yy-1], sizeof(int) * COLS);
            }
            // 最上面一行置空
            memset(board[0], 0, sizeof(int) * COLS);
            linesCleared++;
            y++; // 继续检查同一行(因为上面的行移下来了)
        }
    }
    return linesCleared;
}

注意:消除一行后,要重新检查当前行,因为上面的行移下来后可能也是满的。这个细节很容易漏掉,导致一次只能消一行。

控制台渲染

控制台渲染其实比想象中简单。用system("cls")清屏,然后用循环逐行打印。每个格子用两个字符表示,比如"[]"表示方块," "表示空白。

为了让游戏更流畅,我建议用SetConsoleCursorPosition(Windows)或ANSI转义序列(Linux)来定位光标,而不是每次都清屏重绘。这样能避免屏幕闪烁。

void render() {
    // 使用光标定位,避免闪烁
    gotoxy(0, 0);
    for (int y = 0; y < ROWS; y++) {
        for (int x = 0; x < COLS; x++) {
            if (board[y][x] != 0) {
                cout << "[]";
            } else {
                cout << "  ";
            }
        }
        cout << endl;
    }
}

游戏循环与计时

游戏的主循环其实就是一个无限循环,每次循环做三件事:

  1. 处理用户输入(非阻塞方式读取键盘)
  2. 更新游戏状态(方块下落、消行等)
  3. 渲染画面

下落速度怎么控制?用clock()chrono库记录时间,每隔一定毫秒让方块下落一格。随着等级提高,间隔时间逐渐缩短。

while (!gameOver) {
    // 处理输入
    if (_kbhit()) {
        char key = _getch();
        handleInput(key);
    }
    // 计时下落
    if (clock() - lastDrop > dropInterval) {
        dropPiece();
        lastDrop = clock();
    }
    // 渲染
    render();
}

性能优化:不要在每次循环里都渲染整个画面。可以只更新变化的部分,比如只重绘当前方块所在的行。对于控制台游戏来说,这能显著提升流畅度。

SVG:俄罗斯方块核心逻辑流程图

俄罗斯方块游戏核心逻辑流程图 游戏初始化 生成新方块 碰撞检测 通过 方块下落一格 消行判定 失败 方块固定到面板 游戏结束? 游戏结束 循环

避坑指南

最后分享几个我踩过的坑:

  • 键盘输入阻塞问题:默认的cin是阻塞的,游戏会卡住。要用_kbhit()_getch()(Windows)或设置终端为非阻塞模式(Linux)。
  • 方块旋转后位置偏移:旋转后方块的参考点变了,需要做位置补偿。我建议把旋转中心设在方块矩阵的中心,这样旋转后位置变化最小。
  • 消行后分数计算:一次消多行和分多次消一行,分数应该不同。经典规则是一次消1行得100分,2行得300分,3行得500分,4行得800分。
  • 控制台窗口大小:默认控制台窗口可能不够大,用system("mode con cols=30 lines=25")调整大小。

俄罗斯方块这个项目,说难不难,说简单也不简单。它涵盖了C++项目开发中大部分基础技能:数组操作、函数封装、状态管理、事件循环。做完这个项目,你对C++的理解会上一个台阶。嗯,至少我当年是这样的。


公众号:蓝海资料掘金营,微信deep3321