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种方块的所有旋转状态都预计算好,存成一个常量表。运行时直接查表,比实时计算快得多。我早期版本就是实时计算,结果在低配机器上偶尔会卡顿。
碰撞检测
方块能不能左移?能不能旋转?能不能继续下落?这些都需要碰撞检测。逻辑其实很简单:
- 假设方块移动到了新位置
- 检查新位置是否超出左右边界或下边界
- 检查新位置是否与面板上已有的方块重叠
- 如果以上任意一条成立,则移动无效
代码实现大概长这样:
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;
}
}
游戏循环与计时
游戏的主循环其实就是一个无限循环,每次循环做三件事:
- 处理用户输入(非阻塞方式读取键盘)
- 更新游戏状态(方块下落、消行等)
- 渲染画面
下落速度怎么控制?用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++的理解会上一个台阶。嗯,至少我当年是这样的。