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

日记详情

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

C++二维数组实战:从零构建俄罗斯方块游戏引擎

C++二维数组实战:从零构建俄罗斯方块游戏引擎

1. 项目概述:二维数组与俄罗斯方块的奇妙联结

很多C++初学者在学完一维数组后,面对二维数组总觉得隔了一层纱,概念懂了,但真要用它做个什么,又不知从何下手。我自己当年也是这样,直到我决定用二维数组去实现一个最经典的游戏——俄罗斯方块。这个项目就像一个绝佳的“脚手架”,它把抽象的二维数组概念,直接映射到了我们肉眼可见的、可以交互的游戏画面上。当你看到一个个由二维数组元素控制的方块落下、旋转、消除时,你对二维数组的理解会瞬间从“知道”跃升到“掌控”。

简单来说,这个项目的核心就是用C++的二维数组来构建整个俄罗斯方块游戏的底层数据模型。游戏界面那个10x20(或其它尺寸)的网格,本质上就是一个二维数组;每一个下落的“L”形、“田”字形方块,其形状和位置信息,也存储在一个更小的二维数组里。通过操作这些数组(赋值、判断、清空行),我们就实现了游戏的所有核心逻辑。这不仅仅是学习语法,更是在学习如何用数据结构去建模和解决一个具体的、有趣的问题。无论你是刚接触二维数组的新手,还是想找个项目练手巩固基础,这个实战都能让你收获满满。

2. 核心思路拆解:从数据到画面的桥梁

要理解如何用二维数组做俄罗斯方块,关键在于建立“数据层”与“表现层”分离的思维。我们的大脑和代码主要与数据层(即各种二维数组)打交道,而图形界面只是将这些数据以更友好的方式呈现出来。

2.1 游戏区域建模:主地图数组

整个游戏稳定的、已经落底的方块所构成的场景,我们用一个全局的二维数组来表示,通常称之为mapboard。例如,定义一个高20行、宽10列的数组:int gameBoard[20][10] = {0};。这里的每一个“格子”对应屏幕上的一个方块位置。我们用不同的整数值来代表不同的状态:

  • 0:表示该格子为空。
  • 1或其它非零值:表示该格子已被方块占据(可以用不同的数字代表不同的颜色或方块类型)。

这个gameBoard数组就是游戏的“地基”。所有已经落定无法移动的方块,其信息都会永久地“固化”到这个数组中。

2.2 活动方块建模:形状与状态数组

正在下落的那个方块,我们称之为“活动方块”。它需要两个核心属性:形状位置

形状可以用一个小的二维数组来定义。例如,一个“田”字形方块(O型)可以定义为:

int shapeO[2][2] = { {1, 1}, {1, 1} };

而一个“长条”形方块(I型)可以定义为:

int shapeI[4][4] = { {0, 0, 0, 0}, {1, 1, 1, 1}, {0, 0, 0, 0}, {0, 0, 0, 0} };

注意,我们通常用一个4x4的数组来容纳所有可能的方块形状,这样便于统一处理旋转。数组中的1表示有方块实体,0表示空白。

位置则用一个简单的结构体或两个整型变量(posX,posY)来记录这个4x4形状数组的左上角,相对于整个gameBoard地图的坐标。

这样,在每一帧渲染时,我们都需要做一次“合成”:将活动方块的小数组,根据其当前位置,叠加到主地图gameBoard的大数组上进行逻辑判断(如碰撞检测),同时也叠加到画面上进行绘制。

2.3 核心逻辑循环

游戏的主循环遵循一个清晰的流程:

  1. 处理输入:监听键盘事件,决定是左移、右移、旋转还是加速下落。
  2. 更新游戏状态:根据输入和计时,更新活动方块的位置。这里需要进行碰撞检测,即判断活动方块的新位置是否与gameBoard中已固化的方块重叠,或者是否超出边界。
  3. 固化与消行:当碰撞检测发现活动方块无法继续下落时,就将活动方块小数组中的1“烙印”到gameBoard的对应位置,完成固化。随后,遍历gameBoard的每一行,如果某一行全部为1(非零),则将该行消除,并将上面的所有行整体下移。
  4. 生成新方块:方块固化后,立即随机生成一个新的活动方块,并从顶部开始下落。
  5. 渲染画面:清空上一帧画面,然后根据最新的gameBoard和活动方块的数据,在屏幕上绘制出所有方块。

这个循环的核心操作——碰撞检测、固化、消行——全部都是对二维数组的遍历、判断和赋值操作。理解了这个数据流,代码写起来就有的放矢了。

3. 关键实现细节与避坑指南

理论清晰后,我们进入实操环节。这里有几个实现上的关键点和容易踩坑的地方。

3.1 方块形状的定义与旋转

如何高效地定义七种经典方块(I, J, L, O, S, T, Z)及其四个旋转状态,是一个设计难点。我推荐两种主流方法:

方法一:硬编码所有状态这是最直观的方法。为每一种方块的每一个朝向,单独定义一个4x4的二维数组。例如,T型方块有4个方向,就定义4个数组。这种方法代码量稍大,但逻辑极其简单,旋转操作就是切换到下一个预定义的数组。

// T型方块,口朝上 int shapeT_0[4][4] = { {0,1,0,0}, {1,1,1,0}, {0,0,0,0}, {0,0,0,0} }; // T型方块,口朝右 int shapeT_1[4][4] = { {0,1,0,0}, {0,1,1,0}, {0,1,0,0}, {0,0,0,0} }; // ... 其余两个方向

旋转时,只需在当前方块的状态索引上加1(对4取模)即可。

注意:这种方法要特别注意数组的“轴心点”。预定义的形状数组必须保证其旋转是围绕一个合理的中心进行的,否则旋转后的方块会看起来“跳”了一下。通常,我们把形状放在4x4数组的中间偏上区域来定义,这样旋转轴心大致在(2,2)的位置,效果最自然。

方法二:矩阵旋转算法这种方法只存储每个方块的一个“基础形态”(比如0度旋转的状态)。当需要旋转时,通过一个数学上的矩阵转置(然后可能再镜像)算法,实时计算出旋转后的新数组。 对于一个4x4数组,顺时针旋转90度可以这样实现:

void rotateClockwise(int shape[4][4]) { int temp[4][4]; // 先转置 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { temp[j][i] = shape[i][j]; } } // 再左右镜像(即行反转) for (int i = 0; i < 4; ++i) { for (int j = 0; j < 2; ++j) { // 只需交换前两列和后两列 std::swap(temp[i][j], temp[i][3-j]); } } // 将结果复制回原数组 std::memcpy(shape, temp, sizeof(temp)); }

这种方法代码更优雅,存储空间更小,但需要注意“踢墙”判断(Wall Kick)会更复杂。所谓“踢墙”,就是当方块旋转后因为与墙壁或其他方块重叠而无法放置时,系统会尝试将方块向左、右或上微调一个格子,看看能否成功旋转。俄罗斯方块官方标准有复杂的踢墙表,我们自己实现时可以做简化,比如只尝试左移或右移一格。

我的选择建议:对于初学者或希望快速看到成果的项目,强烈推荐方法一(硬编码)。它绕开了复杂的旋转计算和踢墙逻辑,让你能更专注于游戏主循环和二维数组操作的练习,挫败感更少,成就感来得更快。

3.2 碰撞检测的精确实现

碰撞检测是游戏逻辑的“守门员”,必须严谨。我们需要在尝试移动或旋转活动方块之前进行预测性检测。

检测函数bool checkCollision(int futureX, int futureY, int futureShape[4][4])的逻辑如下:

  1. 遍历未来形状数组futureShape的每一个格子(4x4)。
  2. 对于数组中值为1的格子(即方块实体部分),计算其在主地图gameBoard中的实际坐标:boardX = futureX + j; boardY = futureY + i;(这里i是行索引,j是列索引)。
  3. 进行三重判断:
    • 是否超出左/右边界boardX < 0 || boardX >= BOARD_WIDTH
    • 是否超出下边界boardY >= BOARD_HEIGHT(注意,顶部溢出通常是允许的,新方块从顶部生成时可能部分在画面外)
    • 是否与已固化方块重叠boardY >= 0 && gameBoard[boardY][boardX] != 0。这里boardY >= 0的判断很重要,因为方块在顶部时,boardY可能为负,这是合法的,不应视为与地图底部重叠。

只有以上所有判断都通过(即没有发生碰撞),移动或旋转操作才能被执行。

实操心得:在写碰撞检测时,最容易犯的错误是忽略了boardY可能为负的情况。如果你发现新方块一出现就报“碰撞”无法下落,十有八九是这个问题。另外,建议把边界常量(如BOARD_WIDTH,BOARD_HEIGHT)定义为全局常量或类成员,而不是散落在代码里的魔法数字,这样修改起来方便,代码也更清晰。

3.3 消行与地图更新算法

当一行被填满时,消行并让上方方块下落,这个效果实现起来非常符合二维数组的操作特性。一个高效且清晰的算法步骤如下:

  1. 从下往上遍历gameBoard的每一行(比如row = BOARD_HEIGHT-1; row >=0; row--)。
  2. 判断当前行是否满行:遍历该行的所有列,如果所有格子都不为0,则标记为满行。
  3. 如果当前行是满行,我们不立即删除它。而是设置一个linesToClear计数器加一。
  4. 如果当前行不是满行,且linesToClear > 0,说明这一行需要向下移动linesToClear行。那么,我们可以直接执行:gameBoard[row + linesToClear] = gameBoard[row];(这里假设是整行复制)。在C++中,对于原生二维数组,这需要逐列复制,或者用memcpy
  5. 遍历完所有行后,最顶部的linesToClear行现在应该是空的(因为数据已经被移下去了)。我们将这些行全部清零。
  6. 根据消除的行数linesToClear更新分数。

这个算法的妙处在于它只遍历了数组一次,并且是在原地完成的,没有使用额外的临时大数组,效率很高。

int linesCleared = 0; for (int row = BOARD_HEIGHT - 1; row >= 0; --row) { bool rowIsFull = true; for (int col = 0; col < BOARD_WIDTH; ++col) { if (gameBoard[row][col] == 0) { rowIsFull = false; break; } } if (rowIsFull) { linesCleared++; } else if (linesCleared > 0) { // 将当前行下移 linesCleared 行 for (int col = 0; col < BOARD_WIDTH; ++col) { gameBoard[row + linesCleared][col] = gameBoard[row][col]; } } } // 清空最顶部的 linesCleared 行 for (int topRow = 0; topRow < linesCleared; ++topRow) { for (int col = 0; col < BOARD_WIDTH; ++col) { gameBoard[topRow][col] = 0; } } // 更新分数 score += calculateScore(linesCleared);

4. 从控制台到图形界面:渲染方案选择

我们的游戏逻辑完全基于二维数组,这与渲染方式无关。你可以选择最简单的控制台字符界面,也可以使用图形库。

4.1 控制台版本(快速原型)

使用控制台输出空格和特定字符(如[]##)来绘制方块。优点是零依赖,编译运行快,非常适合验证核心逻辑。

  • 核心函数void draw()。在函数内先清屏(system("cls")在Windows上),然后双重循环遍历gameBoard和活动方块,在对应位置输出字符。
  • 局限性:刷新会有闪烁,颜色和界面美观度有限。

4.2 图形库版本(推荐)

使用像EasyX(Windows)、SDL2(跨平台)或SFML(跨平台)这样的轻量级图形库,可以做出更美观的游戏。

  • EasyX:对C++初学者最友好,语法简单,直接在Windows下使用。绘制一个方块只需要setfillcolor(颜色)fillrectangle(左, 上, 右, 下)
  • SDL2/SFML:功能更强大,跨平台,适合想深入学习游戏开发的同学。它们提供了窗口、图形、事件、声音等一套完整的解决方案。

以EasyX为例的绘制片段

#include <graphics.h> // EasyX头文件 void drawBlock(int x, int y, COLORREF color) { // 假设每个方块像素大小为 BLOCK_SIZE int pixelX = x * BLOCK_SIZE; int pixelY = y * BLOCK_SIZE; setfillcolor(color); solidrectangle(pixelX, pixelY, pixelX + BLOCK_SIZE - 1, pixelY + BLOCK_SIZE - 1); // 画一个边框让方块更立体 setlinecolor(BLACK); rectangle(pixelX, pixelY, pixelX + BLOCK_SIZE - 1, pixelY + BLOCK_SIZE - 1); } void render() { cleardevice(); // 清空画面 // 1. 绘制已固化的地图 for (int y = 0; y < BOARD_HEIGHT; ++y) { for (int x = 0; x < BOARD_WIDTH; ++x) { if (gameBoard[y][x] != 0) { drawBlock(x, y, getColor(gameBoard[y][x])); // getColor根据数字返回颜色 } } } // 2. 绘制当前活动方块 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { if (currentShape[i][j] != 0) { int boardX = currentPosX + j; int boardY = currentPosY + i; if (boardY >= 0) { // 只绘制在地图内的部分 drawBlock(boardX, boardY, CURRENT_COLOR); } } } } // 3. 可以在这里绘制分数、下一个方块预览等UI // ... }

图形库的引入,让我们的二维数组数据终于有了漂亮的“皮囊”,项目成就感直接拉满。

5. 项目结构设计与代码组织

当功能越来越多时,一个好的代码结构能让你后期修改和维护事半功倍。不建议把所有代码都堆在main.cpp里。

推荐的项目文件结构

tetris/ ├── main.cpp // 程序入口,主循环 ├── game.h // 游戏核心类声明 ├── game.cpp // 游戏核心类实现(管理地图、方块、分数、状态) ├── tetromino.h // 方块类声明 ├── tetromino.cpp // 方块类实现(形状、旋转、绘制) ├── renderer.h // 渲染器抽象接口或具体声明 ├── renderer_console.cpp // 控制台渲染实现 ├── renderer_easyx.cpp // EasyX渲染实现 └── constants.h // 存放所有常量(地图大小、颜色、方块定义等)

核心类设计示例(简化): 在game.h中:

#pragma once #include "constants.h" #include "tetromino.h" class Game { private: int board[BOARD_HEIGHT][BOARD_WIDTH] = {0}; // 主地图 Tetromino currentPiece; // 当前活动方块 Tetromino nextPiece; // 下一个预览方块 int score; bool isGameOver; // ... 其他状态,如等级、下落速度等 public: Game(); void init(); // 初始化游戏 bool update(int input); // 根据输入更新游戏状态,返回游戏是否结束 void draw(); // 渲染游戏(委托给渲染器) // ... 其他方法,如碰撞检测、消行等 };

tetromino.h中:

#pragma once #include "constants.h" class Tetromino { private: int shape[4][4]; // 当前形状 int type; // 方块类型 (I, J, L...) int rotation; // 当前旋转状态 (0-3) int posX, posY; // 位置 public: Tetromino(); void setRandomType(); // 随机设置一个类型 void rotate(); // 旋转 int getCell(int y, int x) const; // 获取形状数组特定位置的值 // ... 获取位置、设置位置等方法 };

这样设计后,main.cpp里的主循环会非常干净:

#include "game.h" #include "renderer.h" int main() { Game game; game.init(); Renderer* renderer = new EasyXRenderer(); // 选择渲染器 while (!game.isOver()) { int userInput = getInput(); // 获取用户输入 game.update(userInput); renderer->draw(game); // 渲染器绘制整个游戏对象 delay(game.getSpeed()); // 控制游戏速度 } // 游戏结束处理 delete renderer; return 0; }

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

在开发过程中,你几乎一定会遇到下面这些问题。别担心,这都是必经之路。

问题一:方块旋转时位置“跳变”或看起来没绕中心转。

  • 原因:你的形状数组定义没有考虑旋转中心。比如,你把“L”型方块紧贴着4x4数组的左上角定义,旋转时它的视觉中心就会在角落,导致跳动。
  • 解决:定义形状时,尽量让方块的“质量中心”位于4x4数组的中间。对于大多数方块,可以将其“主体”放在数组的(1,1)附近。多调整几次数组里1的位置,直到旋转动画看起来自然。

问题二:方块在底部或边缘旋转时,有时会卡进墙里或已固化方块里。

  • 原因:缺少“踢墙”处理。当旋转后的新形状与边界或已有方块重叠时,需要尝试微调位置。
  • 解决:实现一个简单的踢墙逻辑。在rotate()函数里,先尝试旋转,然后调用checkCollision检测。如果碰撞了,依次尝试将旋转后的方块向左、向右、向上(根据俄罗斯方块标准规则,通常先左后右)移动一格,看哪个位置不碰撞,就采用那个位置。如果所有位置都碰撞,则旋转失败,恢复到旋转前的状态。

问题三:消行后,上面的方块没有正确下落,或者出现了“悬空”方块。

  • 原因:消行算法有漏洞。最常见的是从上往下遍历,导致数据被覆盖。或者是移动行时,没有处理多行消除的情况。
  • 解决:严格使用我上面推荐的从下往上遍历的算法,并仔细检查数组下标的计算。可以在消行前后打印出整个gameBoard数组的内容,对比看看数据变化是否符合预期。

问题四:游戏速度控制不准,时快时慢。

  • 原因:直接使用Sleep()delay()函数,但没有考虑一帧内逻辑计算和渲染所消耗的时间,导致帧时间不稳定。
  • 解决:实现一个简单的帧率控制。记录每一帧开始的时间戳,在帧结束时计算本帧耗时,然后Sleep(目标帧时间 - 本帧耗时)。这样可以保证游戏速度稳定,不受机器性能波动太大影响。
const int TARGET_FRAME_TIME_MS = 33; // 约30帧/秒 auto frameStart = std::chrono::steady_clock::now(); // ... 执行本帧的游戏逻辑和渲染 ... auto frameEnd = std::chrono::steady_clock::now(); auto frameDuration = std::chrono::duration_cast<std::chrono::milliseconds>(frameEnd - frameStart).count(); int sleepTime = TARGET_FRAME_TIME_MS - frameDuration; if (sleepTime > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime)); }

问题五:键盘输入有延迟或不灵敏。

  • 原因:控制台或某些图形库的输入函数是“阻塞”或“缓冲”的,需要按回车,或者无法处理连续按键。
  • 解决
    • 控制台:使用_kbhit()_getch()(Windows)或kbhit()getch()(Linux/Conio兼容库)来检测和获取无缓冲的键盘输入。
    • 图形库:如EasyX、SDL2,它们都有专门的非阻塞事件检测函数(如peekmessage,SDL_PollEvent),一定要在主循环里持续调用这些函数来处理输入。

调试时,最朴素的printfstd::cout大法依然有效。在关键节点(如碰撞检测前、消行后)打印出gameBoard和活动方块的位置、形状数据,能帮你快速定位逻辑错误。当你看到二维数组里的0和1按照你的预期变化时,那种对程序的控制感是无与伦比的。

这个项目做下来,你会对C++二维数组的内存布局、遍历、传参(特别是作为函数参数时,要注意数组衰减为指针)有肌肉记忆般的理解。更重要的是,你完成了一个有头有尾、可以展示的作品。你可以在此基础上无限扩展:增加分数系统、增加难度等级(下落速度随分数增加)、增加下一个方块预览、增加音效、甚至增加多人对战模式。每一个新功能,都是对你编程能力的又一次锤炼。

← 返回列表