三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

C++实现俄罗斯方块:从控制台到图形界面的游戏开发实战

C++实现俄罗斯方块:从控制台到图形界面的游戏开发实战

1. 项目概述:为什么用C++写俄罗斯方块依然经典?

最近在整理硬盘,翻出来一个十几年前用VC6写的俄罗斯方块项目,编译运行了一下,居然还能玩。这让我感慨良多,也让我重新审视了这个看似“古老”的编程练习。今天,我想和你聊聊,为什么在Unity、Unreal、Godot等现代引擎大行其道的今天,用纯C++从零开始实现一个俄罗斯方块,依然是一个极具价值的实战项目,尤其对于想深入理解C++和游戏底层逻辑的朋友。

俄罗斯方块,规则简单到几乎人人都懂:七种不同形状的方块(Tetromino)从屏幕顶端落下,玩家通过旋转、平移使其在底部堆叠,填满一行即可消除。但正是这种极简的规则,让它成为了检验程序员基本功的绝佳试金石。它麻雀虽小,五脏俱全:你需要处理图形渲染(哪怕是字符界面)、用户输入、游戏逻辑(碰撞检测、消行判断)、状态管理,甚至计时器和分数系统。用C++来实现,意味着你需要亲手搭建这些轮子,而不是调用现成的GameObjectComponent。这个过程,能让你透彻理解一个游戏是如何“跑”起来的,从数据到画面,从逻辑到交互。

对于初学者,这是从“语法学习”到“项目实战”的关键一跃;对于有经验的开发者,这是重温基础、优化设计模式的绝佳机会。我们这次的目标,不是做一个炫酷的3D版本,而是回归本质,用控制台(Console)作为显示界面,专注于用C++的面向对象思想来构建一个清晰、健壮的游戏架构。你会发现,没有花哨的界面,代码的逻辑之美反而更加凸显。

2. 核心架构设计与模块拆解

在动手写第一行代码之前,好的设计能让我们事半功倍。一个结构混乱的俄罗斯方块,代码会像纠缠在一起的耳机线,改一处而动全身。我们的目标是高内聚、低耦合,让每个类职责单一。

2.1 整体架构与类设计思路

我采用的是一种经典的分层架构,主要包含以下几个核心类:

  1. Game(游戏主控类):这是游戏的大脑和总指挥。它负责协调所有其他模块,管理游戏的主循环(Game Loop),处理游戏状态的切换(如开始、暂停、结束),并持有当前活跃方块和游戏地图的引用。
  2. Tetromino(方块类):这是游戏的核心实体。它需要封装一个方块的所有属性和行为:形状(7种之一)、颜色(在控制台中可以用不同字符或颜色表示)、当前位置(行、列)、当前旋转状态。它的核心方法是旋转、移动(左、右、下)以及绘制自身。
  3. Board(游戏板类):代表那个有固定宽度和高度的网格地图。它本质上是一个二维数组(或向量),每个单元格记录着是“空”还是被某个“已固定”的方块占据。它的职责包括:检查方块移动/旋转是否合法(碰撞检测)、将当前活跃方块“固化”到地图上、检查并消除已填满的行、计算得分。
  4. Renderer(渲染器类):负责将所有游戏状态“画”到屏幕上。在控制台环境下,我们需要处理光标定位、颜色设置、字符输出。这个类将游戏逻辑(Board的数据)和显示细节隔离开,未来如果想换成图形库(如SDL、SFML),只需重写这个类。
  5. InputHandler(输入处理器类):负责在游戏循环中非阻塞地获取用户的键盘输入(如左右箭头、上箭头旋转、空格键加速下落),并将其转化为游戏逻辑能理解的操作命令。

它们之间的关系可以这样理解:Game类驱动着一切。在每一帧中,Game通过InputHandler获取输入,然后命令当前的Tetromino尝试移动或旋转,并询问Board是否允许此操作。如果允许,就更新状态;如果不允许(比如触底),则Board将方块固化,并检查消行。最后,Game调用Renderer,将最新的BoardTetromino状态绘制到控制台。

2.2 数据结构选型:为什么用std::vector和位运算?

游戏板(Board)的存储:最直观的是使用二维数组,比如int board[20][10]。但静态数组大小固定,且传递起来不太方便。我更喜欢使用std::vector<std::vector<int>>。外层vector代表行,内层vector代表列。这样做的灵活性在于,我们可以轻松地获取板子的高度(board.size())和宽度(board[0].size()),并且可以利用vector的方法,比如在消行时,很容易删除一行并在顶部插入新的空行。每个单元格的int值可以表示状态:0为空,非0则可以表示不同颜色或方块类型。

方块形状的定义与旋转:七种方块(I, J, L, O, S, T, Z)的形状可以用一个4x4的矩阵来表示。一个非常高效且经典的方法是使用位掩码(Bitmask)。对于每个方块,我们用一个16位的整数(unsigned short)来表示其4x4网格。例如,T型方块初始形态可以表示为:

二进制:0000 0111 0010 0000 (只关注4x4区域) 对应矩阵:0 1 0 0 1 1 1 0 0 0 0 0 0 0 0 0

我们为每种方块定义4个这样的整数,分别对应其0、90、180、270度的旋转状态。旋转操作就变成了从一个预定义的整数数组切换到下一个索引。判断方块是否与地图碰撞,就变成了将方块的位掩码与地图对应区域的“占用位”进行“按位与”操作,结果不为0则碰撞。这种方法效率极高,且代码简洁。

注意:使用位掩码需要对二进制操作比较熟悉。如果觉得难以理解,初期可以用二维vector或数组来定义形状,逻辑会更直观,但旋转算法的实现会稍复杂一些。

3. 核心模块实现详解

有了清晰的设计图,我们就可以开始动手搭建了。我们从最基础的实体——方块开始。

3.1 Tetromino方块类的实现

方块类的头文件tetromino.h可能长这样:

#ifndef TETROMINO_H #define TETROMINO_H #include <vector> // 方块类型枚举 enum class TetrominoType { I, J, L, O, S, T, Z }; class Tetromino { public: // 构造函数,初始化一个指定类型的方块 Tetromino(TetrominoType type); // 获取当前形状的矩阵表示(用于碰撞检测和渲染) const std::vector<std::vector<int>>& getShape() const; // 获取方块颜色或标识(控制台可以用不同字符) int getCell() const; // 获取当前行、列位置 int getRow() const { return row_; } int getCol() const { return col_; } // 操作:旋转、左右移动、下落 void rotate(); void moveLeft(); void moveRight(); void moveDown(); // 撤销上一次移动(用于碰撞检测后回退) void undoMove(); // 设置/获取位置 void setPosition(int row, int col); private: TetrominoType type_; // 方块类型 int rotationIndex_; // 当前旋转状态索引 (0-3) int row_, col_; // 方块左上角在游戏板中的坐标 int prevRow_, prevCol_; // 上一次的位置,用于回退 std::vector<std::vector<int>> shape_; // 当前旋转状态下的形状矩阵 // 根据类型和旋转索引,重新计算shape_ void updateShape(); // 预定义所有方块所有旋转状态的形状数据 static const std::vector<std::vector<std::vector<int>>> shapesData; }; #endif // TETROMINO_H

tetromino.cpp中,我们需要初始化那个庞大的静态数据shapesData。这里我采用直观的二维向量来定义,例如L型方块的四种形态:

const std::vector<std::vector<std::vector<int>>> Tetromino::shapesData = { // I型 { { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} }, ... }, // J型 { { {1,0,0,0}, {1,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,1,0}, {0,1,0,0}, {0,1,0,0}, {0,0,0,0} }, ... }, // ... 其他类型 };

updateShape()函数很简单,就是从shapesData中根据type_rotationIndex_取出对应的形状矩阵赋值给shape_rotate()函数则增加rotationIndex_并对4取模,然后调用updateShape()

实操心得:在moveLeft,moveRight,moveDown这些改变位置的方法里,我习惯先更新prevRow_, prevCol_保存旧位置,再更新新位置。这样在undoMove()中就能轻松回退。这在碰撞检测失败时非常有用,可以优雅地将方块“弹回”原位,而不是让逻辑变得复杂。

3.2 Board游戏板类的实现

游戏板类是逻辑最密集的地方。它的头文件board.h需要暴露一些关键接口:

class Board { public: Board(int height, int width); // 核心:检查当前方块能否放置在指定位置(或执行操作后的位置) bool isValidPosition(const Tetromino& t) const; // 将当前方块“固化”到板子上 void placeTetromino(const Tetromino& t); // 检查并消除满行,返回消除的行数用于计分 int clearFullLines(); // 获取板子状态,用于渲染 const std::vector<std::vector<int>>& getGrid() const { return grid_; } // 判断游戏是否结束(新方块无法放置) bool isGameOver(const Tetromino& t) const; int getScore() const { return score_; } void addScore(int lines); // 根据消除行数加分 private: std::vector<std::vector<int>> grid_; // 游戏网格,0为空 int height_, width_; int score_; };

isValidPosition是碰撞检测的核心。我们需要遍历当前方块t的形状矩阵shape_,对于每一个值为非0的单元格,计算其在游戏板grid_中的实际坐标(boardRow, boardCol),然后检查:

  1. 是否超出板子左右边界或下边界 (boardCol < 0 || boardCol >= width_ || boardRow >= height_)。
  2. 该坐标在grid_中是否已被占用(值非0)。 只要有一个单元格不满足条件,就返回false

placeTetromino方法则相对简单,遍历方块的非空单元格,将其值(或颜色标识)写入grid_的对应位置。

clearFullLines的实现是算法的一个小亮点。我们需要从下往上遍历每一行,判断该行是否全部非0(即已填满)。如果填满,则将该行删除,并在顶部插入一个全0的新行。同时,被消除行上方的所有行需要依次向下移动一行。这里有一个技巧:我们可以用一个临时vector来收集所有未满的行,清空grid_后,先在顶部补足因消行而缺失的空行,再将未满的行添加回去。这样逻辑更清晰。

int Board::clearFullLines() { std::vector<std::vector<int>> newGrid; int linesCleared = 0; // 从下往上检查 for (int r = height_ - 1; r >= 0; --r) { bool lineFull = true; for (int c = 0; c < width_; ++c) { if (grid_[r][c] == 0) { lineFull = false; break; } } if (!lineFull) { // 未满的行,保留 newGrid.push_back(grid_[r]); } else { // 满行,计数增加 linesCleared++; } } // 补充顶部空行 while (newGrid.size() < height_) { newGrid.push_back(std::vector<int>(width_, 0)); } // 反转,因为我们是自底向上收集的 std::reverse(newGrid.begin(), newGrid.end()); grid_ = std::move(newGrid); // 使用移动语义提高效率 return linesCleared; }

计分策略:通常消除的行数越多,单次得分越高。一个常见的规则是:1行得100分,2行得300分,3行得500分,4行(Tetris)得800分。这可以在addScore方法中实现。

3.3 游戏主循环与状态管理

Game类将一切串联起来。它的核心是一个run()函数,里面包含经典的游戏循环:处理输入 -> 更新逻辑 -> 渲染输出。对于控制台游戏,我们还需要处理一个关键问题:如何控制游戏速度(方块自动下落)?

我们不能让循环无限制地全速运行,那样方块会瞬间掉到底部。我们需要引入时间控制。一个简单的方法是使用std::chrono库。

void Game::run() { using clock = std::chrono::steady_clock; auto lastUpdateTime = clock::now(); const std::chrono::milliseconds fallInterval(500); // 初始下落间隔500毫秒 while (isRunning_) { auto currentTime = clock::now(); auto deltaTime = currentTime - lastUpdateTime; // 1. 处理输入(非阻塞) handleInput(); // 2. 更新逻辑 // 如果距离上次自动下落的时间超过间隔,则触发一次下落 if (std::chrono::duration_cast<std::chrono::milliseconds>(deltaTime) >= fallInterval) { update(); // 这个update里会执行一次自动下落逻辑 lastUpdateTime = currentTime; } // 3. 渲染 renderer_.render(board_, currentTetromino_, nextTetromino_, score_); // 4. 简单延时,控制帧率,避免CPU占用率100% std::this_thread::sleep_for(std::chrono::milliseconds(16)); // 约60FPS } }

update()函数中,我们主要做以下几件事:

  1. 自动下落:让当前方块尝试向下移动一格。如果移动失败(!board.isValidPosition(tetrominoAfterMove)),说明方块触底了。
  2. 固化与消行:方块触底后,调用board.placeTetromino(...)将其固化。然后检查消行,并更新分数。根据消行数,可以适当减少fallInterval以加快游戏速度,增加难度。
  3. 生成新方块:固化后,立即生成下一个新的随机方块作为当前方块。同时,再生成一个更新的“下一个方块”用于预览。
  4. 游戏结束判断:生成新方块后,立即检查它能否被放置在初始位置。如果不能,则游戏结束。

输入处理:在handleInput()中,我们需要使用非阻塞的方式检测键盘按键。在Windows平台,可以用_kbhit()_getch()(来自<conio.h>);在Linux/macOS,可以使用ncurses库或终端原始模式。根据按下的键(左箭头、右箭头、上箭头、下箭头、空格),调用当前方块对应的方法,并在每次操作后立即用board.isValidPosition()检查合法性,不合法则调用方块的undoMove()

4. 控制台渲染的“花样”与交互优化

在图形界面普及之前,很多经典游戏都是在字符控制台里完成的。用C++写控制台俄罗斯方块,渲染部分虽然不涉及OpenGL/DirectX,但也有不少技巧和坑。

4.1 双缓冲区与光标控制

如果你直接在控制台里循环打印整个游戏画面,会看到严重的闪烁。这是因为你打印的过程被用户看到了。解决方法是使用双缓冲区:先在内存中(比如一个字符串或二维字符数组)构建好完整的一帧画面,然后一次性输出到控制台。

在Windows下,我们可以用SetConsoleCursorPositionSetConsoleCursorInfo来精确控制光标位置和隐藏光标闪烁。

class ConsoleRenderer { public: ConsoleRenderer(int boardHeight, int boardWidth) { hConsole = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cursorInfo; GetConsoleCursorInfo(hConsole, &cursorInfo); cursorInfo.bVisible = false; // 隐藏光标 SetConsoleCursorInfo(hConsole, &cursorInfo); // 预留足够大的缓冲区 buffer_.resize(boardHeight + 2, std::string(boardWidth + 2, ' ')); } void render(const Board& board, const Tetromino& current, const Tetromino& next, int score) { // 1. 清空缓冲区 clearBuffer(); // 2. 绘制边框 drawBorder(); // 3. 绘制已固定的方块(从board.grid_读取) for (int r = 0; r < board.getHeight(); ++r) { for (int c = 0; c < board.getWidth(); ++c) { if (board.getGrid()[r][c] != 0) { buffer_[r + 1][c + 1] = '#'; // 用#表示方块 } } } // 4. 绘制当前正在下落的方块 auto shape = current.getShape(); for (int r = 0; r < shape.size(); ++r) { for (int c = 0; c < shape[r].size(); ++c) { if (shape[r][c] != 0) { int boardR = current.getRow() + r; int boardC = current.getCol() + c; if (boardR >= 0 && boardR < board.getHeight() && boardC >= 0 && boardC < board.getWidth()) { buffer_[boardR + 1][boardC + 1] = '@'; // 用@表示当前活动方块 } } } } // 5. 绘制侧边栏(下一个方块预览,分数) drawSidebar(next, score); // 6. 一次性输出缓冲区到控制台 SetConsoleCursorPosition(hConsole, {0, 0}); for (const auto& line : buffer_) { std::cout << line << std::endl; } } private: HANDLE hConsole; std::vector<std::string> buffer_; void clearBuffer() { /* 用空格填充buffer_ */ } void drawBorder() { /* 画边框线 */ } void drawSidebar(const Tetromino& next, int score) { /* 在buffer_右侧区域画预览和分数 */ } };

注意:上述代码是Windows API示例。如果你需要跨平台,可以考虑使用像ncurses(Linux/macOS) 或PDCurses(跨平台) 这样的库,它们封装了终端控制功能,让双缓冲区和光标控制更简单。

4.2 颜色与方块区分

为了让游戏更美观,我们可以给不同的方块类型上色。Windows控制台可以使用SetConsoleTextAttribute函数。

void setColor(int colorCode) { SetConsoleTextAttribute(hConsole, colorCode); } // 在绘制每个单元格前调用setColor,绘制后重置为默认颜色。

颜色码是前景色和背景色的组合。例如,FOREGROUND_RED | FOREGROUND_INTENSITY是亮红色。你可以为7种方块定义7种不同的颜色。

交互优化:除了基本的左右移动、旋转和加速下落,还可以实现一些提升体验的功能:

  • 影子(Ghost Piece):在半透明或灰色的位置显示方块如果立即下落会到达的位置,帮助玩家预判。
  • 暂存(Hold):允许玩家将当前方块暂存起来,之后可以换出。这需要增加一个状态变量。
  • 下一个方块预览(Next):我们已经实现了,这是标准配置。
  • 音效:虽然控制台发声比较古老,但Windows下可以用Beep(frequency, duration)函数在消行时发出简单的提示音。

5. 进阶话题:从控制台到图形界面

当你用控制台完美实现了游戏的所有逻辑后,你已经拥有了一个健壮的、可测试的“游戏引擎”。这时,将显示部分从控制台字符替换成图形界面,就变得水到渠成。你可以选择轻量级的图形库,比如SFMLSDL2

5.1 使用SFML进行图形化改造

SFML(Simple and Fast Multimedia Library)非常适合2D游戏,API直观。改造过程可以高度模块化:

  1. 剥离Renderer:将我们之前写的ConsoleRenderer类替换成SFMLRenderer类。这个类不再操作控制台光标和字符,而是操作SFML的sf::RenderWindow,sf::RectangleShape,sf::Font,sf::Text等对象。
  2. 资源管理:为每种方块准备一个小图片(精灵图)或直接用不同颜色的矩形绘制。
  3. 重写渲染循环:SFML有自己的事件循环。你需要将原来的Game::run()中的循环改为SFML的window.pollEvent()window.display()循环。游戏逻辑更新 (update) 的部分可以放在每帧中,或者仍然用独立的时间间隔控制下落速度。
  4. 输入处理:从_getch()改为处理sf::Event::KeyPressed事件。

核心变化在于渲染部分。原来在缓冲区填字符,现在变成了在窗口上画矩形:

void SFMLRenderer::render(const Board& board, const Tetromino& current, ...) { window_.clear(sf::Color::Black); // 1. 绘制已固定的方块 for (int r = 0; r < board.getHeight(); ++r) { for (int c = 0; c < board.getWidth(); ++c) { if (board.getGrid()[r][c] != 0) { sf::RectangleShape cell(sf::Vector2f(cellSize, cellSize)); cell.setPosition(c * cellSize, r * cellSize); cell.setFillColor(getColor(board.getGrid()[r][c])); // 根据格子值获取颜色 window_.draw(cell); } } } // 2. 绘制当前活动方块(类似逻辑) // ... // 3. 绘制边框、分数文本等 // ... window_.display(); }

你会发现,游戏的核心逻辑类Game,Board,Tetromino几乎不需要改动!这就是良好架构带来的好处:显示层与逻辑层解耦。

5.2 性能考量与代码优化

即使是一个简单的俄罗斯方块,也有优化空间:

  • 避免频繁内存分配:在游戏循环中,避免在热路径(如每帧的渲染和更新函数)中进行动态内存分配(如new,std::vectorpush_back)。我们的形状数据是静态的,网格大小固定,这很好。
  • 使用移动语义:在Board::clearFullLines中,我们使用了grid_ = std::move(newGrid),避免了不必要的拷贝。
  • 预计算:方块的旋转数据是预定义的,这是一种空间换时间的优化。
  • 局部位运算:如果坚持使用位掩码表示方块,碰撞检测的isValidPosition函数会非常快,因为它主要是整数的位与操作。

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

开发过程中,你肯定会遇到各种“方块穿墙”、“旋转卡住”、“消行后画面错乱”的问题。分享几个我踩过的坑和调试方法。

问题一:旋转时方块“嵌”进墙里或已固定方块里。

  • 原因:旋转后的新形状,其有效单元格可能超出了方块的原始包围盒。例如,长条(I)方块旋转后,从4x1变成1x4,其参考点(通常是形状矩阵的左上角)没变,但部分单元格可能向左上方“溢出”。
  • 解决方案:这就是著名的“墙踢”(Wall Kick)机制。当旋转检测到碰撞时,不要立即拒绝,而是尝试将方块向旁边移动一格(通常是先右、再左、再下)再检测。俄罗斯方块官方规范有详细的墙踢表。简化实现:在Tetromino::rotate()中,旋转后如果位置非法,可以尝试向左/右微调1-2格,如果调整后合法,就接受这个旋转并更新位置。

问题二:游戏速度随着分数增加变得难以控制。

  • 原因:下落间隔fallInterval递减策略太激进,或者没有下限。
  • 解决方案:设计一个平滑的难度曲线。例如,每消除10行,下落间隔减少50毫秒,但设置一个最低下限(如100毫秒)。也可以将间隔与分数挂钩,使用公式计算,如interval = max(100, 500 - score / 1000)

问题三:控制台画面闪烁严重。

  • 原因:没有使用双缓冲区,或者每帧渲染前没有清空到正确位置。
  • 解决方案:确保使用我们前面提到的双缓冲区和光标定位技术。关键一步:在每次渲染循环开始时,将光标重置到控制台左上角(0,0),再输出整个缓冲区,这样新帧会完全覆盖旧帧。

问题四:键盘输入有延迟或需要按多次才响应。

  • 原因:控制台输入默认是行缓冲的,需要按回车才提交。我们用了_kbhit_getch是非阻塞的,但可能因为游戏循环太快,按键事件在两次检测之间被错过了。
  • 解决方案:在游戏主循环中,适当增加输入检测的频率,或者将输入检测放在一个独立于帧率的时间间隔里。更稳健的做法是使用多线程,一个线程专用于阻塞式读取输入并放入队列,主循环从队列中读取。但对于这个小游戏,调整循环中的sleep_for时间,确保它不会太快以至于吞掉按键事件,通常就足够了。

调试工具

  • 日志输出:在关键函数(如isValidPosition,placeTetromino)中加入临时日志,输出参数和中间结果到文件,这是最朴素的调试方法。
  • 条件断点:如果你使用Visual Studio、CLion等IDE,可以设置条件断点,比如只在方块类型为T且旋转失败时中断。
  • 单元测试:为BoardisValidPositionclearFullLines等核心逻辑编写简单的单元测试,确保其行为符合预期,这在调整复杂规则(如墙踢)时非常有用。

从一行行代码搭建起一个可以运行、可以游玩的游戏,这种成就感是单纯学习语法无法比拟的。这个项目虽然小,但它贯穿了C++的核心概念:类与对象、STL容器、内存管理、算法逻辑,甚至还有一点点时间处理和平台相关的API调用。更重要的是,它训练了你将复杂问题分解为模块,并逐步实现和调试的系统性思维能力。当你看到自己写的方块流畅下落、旋转、消行时,你会对“程序”和“创造”有更深的理解。

← 返回列表