ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++控制台俄罗斯方块:二维数组、碰撞检测与旋转算法实战

C++控制台俄罗斯方块:二维数组、碰撞检测与旋转算法实战 “C 俄罗斯方块”这个项目我前前后后重写过三遍。第一遍是照着别人的源码抄第二遍是自己关掉参考代码硬写第三遍换成了图形库重写渲染部分。每一次都有新的收获而且都很直接地反映在 C 语法理解上二维数组、结构体、函数传参、随机数、循环控制、状态判断这些小游戏基本全都能练到。这篇东西围绕一份能直接保存运行的控制台版 C 俄罗斯方块源码展开。我会把源码完整贴出来然后重点讲清楚为什么这么写以及哪些地方最容易出 bug。适合刚学完数组和函数、想动手写第一个完整小项目的人如果你已经写过一遍也可以对照着看自己的实现看看旋转和消行这两块是不是有隐藏问题。1. 项目定位为什么俄罗斯方块适合做 C 练手1.1 游戏规则天然就是一套程序状态机俄罗斯方块的规则全球统一不需要你设计复杂的玩法。但它的逻辑链条非常完整方块不断下落玩家左右移动和旋转方块触底后固定满行消除新方块出现游戏继续。这个过程翻译成程序语言其实就是几个核心动作的循环下落当前方块坐标的y加 1左移/右移当前方块坐标的x减 1 或加 1旋转把方块矩阵做一次 90 度变换固定把当前方块拷贝到棋盘数组里消行扫描棋盘数组里的满行整体下移这些动作全部建立在二维数组和坐标计算之上。相比那些只做增删改查的管理系统类练习俄罗斯方块逼着你关心“状态什么时候改变”和“改变前要做什么检查”这两点对后续学任何游戏、模拟器、嵌入式界面都很关键。1.2 控制台版比图形库版更适合第一次上手有人问为什么不直接用 SDL2 或者 SFML 做图形版。我建议第一次写的时候先做控制台版。控制台版不需要安装任何第三方库一个编译器和标准 C 语法就能跑。图形库会让你额外接触窗口创建、事件队列、纹理渲染、硬件加速这些概念这些当然重要但它们会冲淡你对游戏核心逻辑的注意力。俄罗斯方块最值得练的从来不是渲染而是“碰撞检测怎么判断”“旋转之后怎么不卡进墙里”“消行怎么处理才能让上方方块正确落下”。等控制台版能完整跑起来再换成图形库只需要替换渲染和输入部分核心逻辑一行都不用改。这也体现了“逻辑和表现分离”的思想比硬啃图形库舒服得多。1.3 我这份源码的代码组织方式这份源码是单文件的整体用“棋盘二维数组 当前方块结构体”来组织没有上来就硬套一堆类。你可能看到网上很多教程会写成Game、Board、Block三个类这没问题但对初学者来说反而增加了阅读负担。我倾向于先把最核心的数据结构摆出来用函数把动作拆开渲染是一个函数碰撞检测是一个函数旋转是一个函数消行是一个函数。这样每个函数单独拎出来都反看得懂后续你想把它重构la成类也很容易。程序第一步永远是跑起来不是结构最优雅。2. 核心逻辑拆解棋盘、方块、旋转、碰撞2.1 棋盘就用二维数组越简单越稳俄罗斯方块的可见区域通常是 10 列、20 行。源码里这么定义const int W 10; const int H 20; int board[H][W] {0};board[行][列]存储 0 或 10 表示空格1 表示已经有固定方块。为什么用int而不是bool因为以后你想给不同方块上不同颜色可以把 1 改成方块编号 1 到 7这样渲染时就能根据数字分配颜色。这个细节有点像数据库里用数字做状态位扩展性比布尔值好很多。有人可能会想用vectorvectorint代替静态二维数组。实际项目里没问题但第一次写这个游戏时二维数组更直观下标就是坐标访问就是board[i][j]中间不需要理解容器的内存模型。先写跑通再换容器顺序别反。2.2 七种方块怎么表达4x4 矩阵俄罗斯方块一共有 7 种基础方块源码里用了一个三维数组来存int baseShapes[7][4][4] { { {0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0} }, // I { {1,1,0,0},{1,1,0,0},{0,0,0,0},{0,0,0,0} }, // O { {0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0} }, // T { {0,1,1,0},{1,1,0,0},{0,0,0,0},{0,0,0,0} }, // S { {1,1,0,0},{0,1,1,0},{0,0,0,0},{0,0,0,0} }, // Z { {1,0,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0} }, // L { {0,0,1,0},{1,1,1,0},{0,0,0,0},{0,0,0,0} } // J };每个方块用一个 4x4 矩阵表示1 的位置就是方块覆盖的格子0 是空白。为什么统一 4x4因为所有方块的旋转结果都能装进 4x4 的框里I 方块横着最长占 4 格竖起来也占 4 格O 方块虽然只有 2x2放在 4x4 里也很安全。统一尺寸后旋转、碰撞、拷贝都能用同一套代码不用为每种方块单独写特殊情况。这里有一个新手容易踩的坑千万不要直接改baseShapes来做旋转。因为当前方块旋转后下一次生成的新方块还需要用原始形状。所以源码里每个当前方块有自己的cur.grid每次生成时从baseShapes复制一份旋转只改这份副本。2.3 旋转算法先模拟再落地旋转就是矩阵顺时针转 90 度。假设原矩阵是grid[i][j]旋转后的矩阵temp[j][3-i] grid[i][j]。这个公式的本质是新矩阵的行等于旧矩阵的列新矩阵的列等于旧矩阵的行倒过来。你可以拿 I 方块的横向状态手推一遍马上就能理解。旋转函数不能直接覆盖当前方块应该先转出临时数组然后调用碰撞检测void rotateBlock() { int temp[4][4] {0}; for (int i 0; i 4; i) for (int j 0; j 4; j) temp[j][3 - i] cur.grid[i][j]; if (!collide(temp, cur.x, cur.y)) { memcpy(cur.grid, temp, sizeof(temp)); } }如果临时数组旋转后和边界或其他方块冲突就不允许旋转。这就像你停车入位之前先在脑子里模拟一遍方向而不是直接把车怼进车位再看能不能行。很多初学者写旋转只做矩阵变换忘了碰撞检测结果方块靠在右墙边一按旋转直接穿墙或者卡在墙里根源就在这里。2.4 碰撞检测一个函数管住边界和固定方块碰撞检测是整个游戏里最重要的函数但它本身不复杂。核心判断是如果方块里某个非 0 格子按当前坐标映射到棋盘上发现越界或者所在位置已经有固定方块就说明撞了。bool collide(int shape[4][4], int px, int py) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (shape[i][j] 0) continue; int bx px j; int by py i; if (bx 0 || bx W || by H) return true; if (by 0 board[by][bx]) return true; } } return false; }注意if (by 0 ...)这一句。为什么有by 0因为新方块生成时方块矩阵的上半部分可以暂时超出棋盘顶部这在很多俄罗斯方块版本里是允许的相当于“出生区”。如果by是负数就不要去访问board[by]否则数组越界直接崩溃。collide函数的参数是“形状”和“目标坐标”所以它能同时服务左移、右移、下落、旋转四种检测。你只要在改变坐标之前问一句“如果挪到那里会不会撞”就知道这个动作能不能做了。主循环里全部统一走这个函数代码就会非常干净。2.5 消行逻辑从下往上扫别覆盖还没处理的行落定的方块要写进棋盘然后检查是否有满行。源码的消行部分是从最后一行往上扫描for (int i H - 1; i 0; --i) { bool full true; for (int j 0; j W; j) { if (board[i][j] 0) { full false; break; } } if (full) { score 100; for (int k i; k 0; --k) { for (int j 0; j W; j) { board[k][j] board[k - 1][j]; } } for (int j 0; j W; j) { board[0][j] 0; } i; // 重新检查当前行 } }为什么从下往上因为消掉一行后需要把上面的所有数据往下移一行。如果从上往下扫移动时可能覆盖掉下面还没有处理的格子逻辑会乱。每次消行后还要i因为上面一行移下来后当前位置可能仍然是满行需要再检查一次。这里也顺便解释了计分每消一行加 100 分。实际操作中一次性消四行的分数可以做得更刺激但教学版不用纠结这个先把逻辑跑通。3. 源码全文与关键代码讲解3.1 源码怎么看、怎么用这份源码面向 Windows 控制台用到了windows.h和conio.h所以最方便的运行环境是 Visual Studio 或者 MinGW-w64。你把它保存成tetris.cpp直接编译就能跑。如果你只是想先睹为快复制粘贴运行就行。如果想真正理解建议阅读顺序是先看baseShapes明白方块形状再看collide明白移动规则然后看主循环明白整个流程最后再看渲染函数。渲染部分的代码本身没有难度都是坐标定位和字符输出。3.2 完整源码可直接保存运行的 C 版本#include windows.h #include conio.h #include iostream #include cstdlib #include ctime #include cstring using namespace std; const int W 10; const int H 20; int board[H][W] {0}; int score 0; bool gameOver false; int baseShapes[7][4][4] { { {0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0} }, // I { {1,1,0,0},{1,1,0,0},{0,0,0,0},{0,0,0,0} }, // O { {0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0} }, // T { {0,1,1,0},{1,1,0,0},{0,0,0,0},{0,0,0,0} }, // S { {1,1,0,0},{0,1,1,0},{0,0,0,0},{0,0,0,0} }, // Z { {1,0,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0} }, // L { {0,0,1,0},{1,1,1,0},{0,0,0,0},{0,0,0,0} } // J }; struct Block { int grid[4][4]; int x, y; }; Block cur; void gotoXY(int x, int y) { COORD c { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), c); } bool collide(int shape[4][4], int px, int py) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (shape[i][j] 0) continue; int bx px j; int by py i; if (bx 0 || bx W || by H) return true; if (by 0 board[by][bx]) return true; } } return false; } void newBlock() { int id rand() % 7; memcpy(cur.grid, baseShapes[id], sizeof(cur.grid)); cur.x W / 2 - 2; cur.y 0; if (collide(cur.grid, cur.x, cur.y)) gameOver true; } void rotateBlock() { int temp[4][4] {0}; for (int i 0; i 4; i) for (int j 0; j 4; j) temp[j][3 - i] cur.grid[i][j]; if (!collide(temp, cur.x, cur.y)) { memcpy(cur.grid, temp, sizeof(temp)); } } void drawCell(int x, int y, bool filled) { gotoXY((x 1) * 2, y 1); if (filled) cout ##; else cout ; } void draw() { for (int i 0; i H; i) { for (int j 0; j W; j) { drawCell(j, i, board[i][j] ! 0); } } for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (cur.grid[i][j]) { int bx cur.x j; int by cur.y i; if (by 0) drawCell(bx, by, true); } } } gotoXY(0, 0); cout --------------------; gotoXY(0, H 1); cout --------------------; gotoXY(W * 2 4, 0); cout SCORE: score; gotoXY(W * 2 4, 1); cout A:left D:right W:rotate S:drop; } void lockAndClear() { for (int i 0; i 4; i) for (int j 0; j 4; j) if (cur.grid[i][j]) { int by cur.y i; int bx cur.x j; if (by 0) board[by][bx] 1; } for (int i H - 1; i 0; --i) { bool full true; for (int j 0; j W; j) { if (board[i][j] 0) { full false; break; } } if (full) { score 100; for (int k i; k 0; --k) { for (int j 0; j W; j) { board[k][j] board[k - 1][j]; } } for (int j 0; j W; j) { board[0][j] 0; } i; } } } int main() { srand((unsigned)time(0)); HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cci; GetConsoleCursorInfo(hOut, cci); cci.bVisible false; SetConsoleCursorInfo(hOut, cci); newBlock(); while (!gameOver) { if (_kbhit()) { char ch _getch(); if (ch a || ch A) { if (!collide(cur.grid, cur.x - 1, cur.y)) --cur.x; } else if (ch d || ch D) { if (!collide(cur.grid, cur.x 1, cur.y)) cur.x; } else if (ch s || ch S) { if (!collide(cur.grid, cur.x, cur.y 1)) cur.y; } else if (ch w || ch W) { rotateBlock(); } } if (collide(cur.grid, cur.x, cur.y 1)) { lockAndClear(); newBlock(); } else { cur.y; } draw(); Sleep(300); } gotoXY(W / 2 - 5, H / 2); cout GAME OVER; gotoXY(W / 2 - 3, H / 2 1); cout SCORE: score; gotoXY(0, H 3); system(pause); return 0; }3.3 主循环到底在干什么主循环是整个游戏的发动机。先看输入_kbhit()检查有没有按键_getch()读取按键注意它不会阻塞程序所以方块仍然会持续下落。方向键对应的是A 左移D 右移W 旋转S 加速下移一格。很多玩家会期待按 S 直接落到底部这里为了简单只做了一次下移你可以改成循环直到碰撞但第一次练习先别加太多功能。再看下落逻辑每次循环开始判断“如果往下移动一格会碰撞吗”会的话就执行lockAndClear()把当前方块固定到棋盘清除满行然后生成新方块不会碰撞就cur.y让方块下落一格。这个判断和处理的顺序很关键必须放在输入处理之后、渲染之前否则画面和实际状态会错位。最后是渲染draw()先画整个棋盘再画当前方块接着画边框和得分。因为是用光标定位直接覆盖输出所以不需要清屏也就不会出现明显闪烁。Sleep(300)控制下落速度这个值越小游戏越快后续做等级系统就是在这里动态调整。3.4 渲染函数里的几个小细节gotoXY是 Windows 控制台的光标定位函数原理是通过SetConsoleCursorPosition把光标挪到指定位置然后再用cout输出字符。这比反复system(cls)高效得多画面也不会闪。drawCell里填充用##空白用两个空格是为了让每个格子占固定宽度。如果你想让画面更像原版可以把##换成■但要注意源码文件编码和终端编码一致否则会出现乱码。建议第一次跑的时候先用##等跑稳定了再考虑美化。把光标隐藏那三行代码用的是CONSOLE_CURSOR_INFO这个不是必需但留着体验更好否则画面上会有一个闪烁的光标块干扰视线。4. 编译与运行不同工具链怎么跑起来4.1 Visual Studio 的操作路径用 Visual Studio 打开新建一个“控制台应用”项目把你自己的main.cpp内容整个替换掉然后按 F5 编译运行。项目默认使用 MSVC 编译器对这种单文件代码没有任何额外配置。唯一要注意的是如果你在源码里加了中文注释或中文字符串建议把文件保存为 UTF-8 with BOM否则部分系统上会出现乱码。我这份源码里的显示文本都是英文就是为了减少编码问题。4.2 VS Code MinGW 的命令行玩法如果你用的是 VS Code 加 MinGW-w64首先确认g已经在环境变量里。然后打开终端进入源码目录执行g tetris.cpp -o tetris.exe .\tetris.exe编译成功会生成一个tetris.exe双击或者命令行运行都行。如果提示找不到g说明还没装或者没配置环境变量把 MinGW 的bin目录加到PATH里再试。VS Code 里也可以用 IntelliSense 看代码提示但编译还是走命令行最直观。4.3 Linux 或 macOS 怎么迁移这份源码用了windows.h和conio.h所以在 Linux 和 macOS 下不能直接编译。核心逻辑其实已经跨平台了真正依赖 Windows 的只有三处光标定位、按键检测、延时函数。想跨平台的话最简单的替代方案是 ncurses 库它也有mvprintw、getch、napms等对应功能改动主要发生在draw、gotoXY和主循环的输入部分。如果你不想碰 ncurses更顺手的路线是直接转 SDL2。保留现在的collide、rotateBlock、lockAndClear把棋盘渲染到 SDL 的窗口上按键换成 SDL 事件Sleep 换成帧间隔控制。这个转换过程本身就是一次很值得做的重构练习能把“逻辑”和“渲染”的边界摸得很清楚。5. 踩坑记录与调试心得5.1 方块消失或卡进边界这个问题的第一位原因就是旋转时直接改了当前方块没有先用临时变量做碰撞检测。很多人写完矩阵旋转后发现方块转到墙边直接消失就是因为旋转后的位置不合法但代码没有拦截。解决方式就是源码里写的先temp再collide合法才拷贝回来。第二个原因是碰撞检测遍历了 4x4 矩阵里的 0 格子。如果 0 格子也算碰撞那方块左上角明明没有实体也会撞到墙看起来就像方块凭空被挡。所以if (shape[i][j] 0) continue;这一步不能少。5.2 消行后上方砖块塌陷错误典型表现是消了一行但该落下来的行没落下来或者上面和下面的行混在一起。多数情况是扫描方向错了。如果你从第一行往下扫把上方数据往下移时会覆盖掉还没检查的行接下来扫到那些被覆盖的行判断就会失真。从最后一行往上扫配合i重新检查当前行是相对稳妥的写法。5.3 下落速度感觉不均匀按下按键后画面偶尔卡一下主要原因是Sleep和输入处理放在同一个循环里。当玩家快速按多个键时输入处理会消耗额外时间下落的节奏自然会被打乱。控制台版本可以先不管这个问题但当你准备写正式版本时建议改用固定时间步长记录上次下落时间当前时间减去上次时间超过阈值才让方块下落一格按键只负责修改方向不直接控制下落时机。5.4 常见问题速查表现象可能原因解决办法方块一出现就提示游戏结束生成位置本身有碰撞检查cur.x W / 2 - 2是否超出范围检查棋盘是否已经堆到顶部旋转后方块瞬间移位旋转没有碰撞检测先用临时矩阵旋转collide通过后再覆盖当前方块按键没反应_kbhit和_getch用法不对或终端焦点不在窗口确认编译环境支持conio.h运行窗口获得焦点后再按键画面闪得厉害每次都清屏重绘用光标定位覆盖输出不要用system(cls)消行后分数不对满行判断或行下移逻辑有误打印棋盘中间状态看从下往上扫描是否完整方块卡在左右边界碰撞检测没有检查 bx 06. 后续还可以怎么扩展6.1 增加“下一个方块”预览现在每次生成新方块都是随机的玩家看不到下一个方块会很被动。你可以加一个int nextId在newBlock里先保存新的随机值再在界面上画一个 4x4 的预览小窗口。这个需求会让“随机数”的使用多一层状态管理也让游戏体验提升一大截。6.2 控制台版加颜色SetConsoleTextAttribute可以给控制台字符设置前景色和背景色。你可以在drawCell里根据格子的值决定颜色比如方块 id 为 1 显示红色id 为 2 显示黄色。这样控制台版看起来会接近原版。注意设置完颜色后最好恢复默认颜色否则后面的文字颜色会乱。6.3 硬降、等级、最高分硬降就是按空格后让方块直接落到最低点实现起来也不难用一个循环不断把cur.y加 1直到碰撞为止。等级系统可以通过控制消除行数量来调整Sleep的时长比如每消 10 行让下落间隔减少 20 毫秒。最高分数据可以写进本地文件下次启动时读回来这个功能会自然引入文件读写是很好的进阶练习。6.4 从控制台版走向重构如果想把这份代码写成简历项目我不建议继续在控制台版里堆功能。更合理的做法是把它重构成Board、Block、TetrisGame三个类让每个类的职责变明确再配合单元测试把collide、消行、旋转这些核心方法单独验证。俄罗斯方块这种项目面试官最想考察的不是你能不能写出来而是你能不能把碰撞检测、矩阵旋转、状态管理讲清楚。我个人经验是这类小游戏项目很适合拿来反复重写。第一次照着抄第二次只画流程图不看代码重写第三次换成图形库重写。每重写一遍你对二维数组和状态判断的理解都会更扎实。遇到消行或旋转的 bug 时别急着猜把棋盘和方块坐标在终端里打印出来一行一行跟下去比盲目改代码快得多。
返回列表