☰
用C语言开发控制台俄罗斯方块:从二维数组到碰撞检测与旋转算法
2026/10/16 5:20:19 网站建设 项目流程

1. 整体设计与思路拆解

1.1 为什么选俄岁斯方块作为C语言练手项目

先聊聊项目缘起。前两天整理电脑时翻出一个去年写的C语言练手项目——控制台版的俄岁斯方块。这个项目被我拿给不少学C语言的朋友当参考,普遍反馈是“看着简单,真动手写才发现坑挺多”。我最初定下的目标很简单:纯C、不用图形库、单文件能编译。正因为目标简单,它反而很适合拿来梳理二维数组、坐标变换、键盘事件、定时器这些基本功。

俄岁斯方块这个题材有个天然优势:规则简单到一句话能讲完,但逻辑链条不短。一个方块从生成、移动、旋转、碰撞检测到消行计分,每一步都涉及状态判断,完整走一遍下来,几乎把C语言的控制流、数组操作、函数封装全练到了。对比学生常做的学生管理系统、图书管理系统那些增删改查项目,俄岁斯方块的交互更直观,写完能玩,成就感完全不同。

1.2 数据模型是游戏的地基:地图、方块与状态机

动手敲代码之前,先把游戏里所有“实体”抽象成数据。我推荐把整个游戏拆成三块:地图、当前活动方块、游戏状态。这三块搞清楚,后面的代码怎么写都不会乱。

地图就是游戏区域,我习惯定义为int board[ROW][COL]。ROW取20,COL取10,这是俄岁斯方块的经典尺寸。数组里存储的是“这个位置有没有方块”,0表示空格,非0表示已被方块占据。有的实现会在地图数组里多留一圈边界,比如声明board[22][12],把围墙也编码进去,碰撞检测就能统一处理,不用单独判断左右下三个边界。这个技巧很实用,建议直接用。

当前活动方块需要记录两样东西:方块类型和当前位置。方块类型用索引表示,0到6对应7种标准方块;当前位置用方块原点的行、列坐标表示。有的初学者会把方块形状直接写死在代码里,位置变了就复制整个形状数组过去改坐标,这样也能跑,但代码会非常臃肿。正确做法是用一个固定的形状矩阵加一个动态偏移量,渲染和碰撞检测时统一换算成地图坐标。

游戏状态:运行中、暂停、结束。用一个int state标记就够了,0表示运行中,1表示暂停,2表示结束。游戏结束时还要记录分数和各等级消除的行数,这两个变量放全局即可。

1.3 模块划分:一开始就把函数边界划清楚

单文件项目也要有模块意识。我的经验是,在写代码前把功能列一张清单,每个功能对应一个函数,后面调试、加功能都会轻松很多。这张清单大概长这样:

  • 初始化游戏initGame()
  • 随机生成方块spawnBlock()
  • 移动方块moveBlock(int dx, int dy)
  • 旋转方块rotateBlock()
  • 碰撞检测checkCollision()
  • 消行处理clearLines()
  • 渲染画面drawGame()
  • 处理输入handleInput()
  • 主循环gameLoop()

为什么强调一开始就分函数?因为俄岁斯方块的逻辑是环环相扣的——移动之前要检查碰撞,旋转之后要检查越界,方块落定之后要检查消行。每个环节都写成一个干净的函数,才能做到“每个函数只干一件事,干完就返回”。我在第一版时图省事,把移动和碰撞写在同一个函数里,结果调试到后面自己都看不清代码在干嘛,后来老老实实拆开,问题立刻好找很多。

2. 核心数据结构与算法实现

2.1 七种方块形状怎么定义才够简洁

俄岁斯方块总共7种标准形状:I、O、T、S、Z、J、L。定义它们的方式有两种主流方案。

方案一:4x4矩阵。每个形状用一个4x4的二维数组表示,有方块的位置为1,其余为0。比如I形方块就是:

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

优点是好理解,每个方块占一个四行四列的“画布”,旋转时直接对4x4矩阵做变换即可。缺点是同一个方块旋转一次要遍历16个格子,稍显浪费。

方案二:坐标对数组。方块不存成矩阵,而是只记录有方块的那些格子的相对坐标。比如T形方块可以定义为五个格子相对于原点的坐标:

int shapeT[4][2] = { {0, 1}, {1, 0}, {1, 1}, {1, 2} };

这里每个子数组表示(行偏移,列偏移)。这种方法内存占用小,旋转计算快(只要交换坐标),但对初学者来说,直观性比方案一差一些。

我自己的建议是:第一次写,用4x4矩阵方案,逻辑清晰不容易错;等项目跑通之后,如果想优化性能,再改成坐标对方案不迟。两种方案我都写过,实际体验下来,4x4矩阵在屏幕刷新这种视觉操作上性能压力微乎其微,故完全可以放心入门。

由于C语言不允许直接使用变量定义数组长度,形状定义时需要写成统一大小的int shape[7][4][4]:

int shapes[7][4][4] = { // I形 {{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, // O形 {{0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0}}, // T形 {{0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, // S形 {{0,1,1,0},{1,1,0,0},{0,0,0,0},{0,0,0,0}}, // Z形 {{1,1,0,0},{0,1,1,0},{0,0,0,0},{0,0,0,0}}, // J形 {{0,1,0,0},{0,1,1,1},{0,0,0,0},{0,0,0,0}}, // L形 {{0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}} };

所有方块统一用4x4矩阵的另一个好处是:后面旋转函数只需要写一套,对所有方块通用。

2.2 旋转算法:矩阵转置加行反转

旋转是俄岁斯方块里最容易出bug的部分,一定要把公式记牢。顺时针旋转的规则是:对于4x4矩阵,先转置,再把每一行左右反转。先转置,再逐行反转。

转置就是行列互换,new[j][i] = old[i][j]。行反转让第0列和第3列互换、第1列和第2列互换。

代码长这样:

void rotateClockwise(int src[4][4], int dst[4][4]) { for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { dst[j][3 - i] = src[i][j]; } } }

dst[j][3 - i]这一句就是“转置+行反转”的数学表达,相当于一次性完成两步变换,比先转置再反转少了一次循环,而且逻辑更紧凑。O形方块转不转结果一样,这也不会出错。

2.3 碰撞检测:把局部坐标换算成全局坐标

碰撞检测是整个游戏的核心。方块自身形状定义在“相对坐标”里,而地图是“全局坐标”,碰撞检测时要把这两套坐标统一。先算出活动方块每个格子的全局坐标:全局行 = 方块原点行 + 局部行,全局列 = 方块原点列 + 局部列。然后逐一检查这些格子是否越界或已经被占用。

int checkCollision(int row, int col, int blockType) { for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (shapes[blockType][i][j] == 0) continue; int boardRow = row + i; int boardCol = col + j; if (boardRow < 0 || boardRow >= ROW) return 1; if (boardCol < 0 || boardCol >= COL) return 1; if (board[boardRow][boardCol] != 0) return 1; } } return 0; }

注意边界判断的两个细节。第一,上边界boardRow < 0也要检查。方块在顶部时只显示半截是正常现象,但方块不能移到地图外面去。第二,列边界检查要在访问数组之前,否则一旦越界,数组访问会读到脏数据甚至直接崩溃。

2.4 消行与计分规则

消行的思路是所有格子从上往下扫描每一行,如果一整行全不为0,就删掉这一行,把上面所有行都往下挪一格。计分规则我用的经典标准:消1行得10分,2行得30分,3行得60分,4行得100分。分数为什么不按“每行固定分值”算?因为一次性消4行的难度远高于消1行,给一个非线性奖励,玩家才更有动力去摆S、Z形方块叠造型。这个规则虽然简单,但游戏性上很成熟。

int clearLines() { int linesCleared = 0; for (int i = ROW - 1; i >= 0; i--) { int full = 1; for (int j = 0; j < COL; j++) { if (board[i][j] == 0) { full = 0; break; } } if (full) { // 把上面的行整体下移一格 for (int k = i; k > 0; k--) { for (int j = 0; j < COL; j++) { board[k][j] = board[k - 1][j]; } } // 最顶行清零 for (int j = 0; j < COL; j++) { board[0][j] = 0; } linesCleared++; i++; // 因为当前行被新行替代,需要重新检查 } } return linesCleared; }

这里有个容易让人绕晕的点:消掉一行后,为什么循环变量要i++?因为消行导致当前行位置换成了上一行的内容,这一行还得再检查一次是不是满行。如果漏掉i++,连续消两行时第二行会漏判。这个坑我亲眼见不少初学者踩过。

计分函数按消除行数返回对应分数,等级则根据累计消除行数递增。等级越高,方块下落速度越快,这是俄岁斯方块提升挑战性的经典手段。

3. 实操过程与核心环节实现

3.1 环境准备:纯C控制台项目的开发结构

我是在Windows控制台环境开发的,编译器用MinGW自带的gcc。直接用gcc tetris.c -o tetris.exe编译,一个文件搞定,不需要任何第三方库。Linux环境下代码也兼容,只是控制台操作部分要替换成termios相关API,Windows的conio.h在Linux下没有。建议初学者先在Windows下跑通,再考虑跨平台。

整个程序的业务流程可以概括成一句话:进入主循环,每一帧处理一次用户输入,根据计时器决定是否让方块下落一格,然后重绘整个游戏画面。听上去简单,实际操作时有一个细节特别重要:按键输入是非阻塞的。玩家不按键时程序不能停下来等,否则方块不会自动下落。这就需要解决“不等键”这个技术问题。

3.2 解决“不按键就卡死”的问题

C语言标准输入是阻塞式的,scanf、getchar都会等在那里。游戏不能用它们处理键盘输入,否则程序在等待输入时方块永远静止。控制台环境下要做非阻塞输入,Windows下可以用_kbhit()判断缓冲区里有没有按键,有按键再用_getch()读取,没有就继续执行游戏逻辑。下面是处理键盘输入的骨架:

#include <conio.h> void handleInput() { while (_kbhit()) { int key = _getch(); if (key == 224) { // 方向键是扩展码,需要一个额外的字节 key = _getch(); switch (key) { case 75: moveBlock(0, -1); break; // 左箭头 case 77: moveBlock(0, 1); break; // 右箭头 case 80: moveBlock(1, 0); break; // 下箭头 } } else { // 普通按键 switch (key) { case ' ': hardDrop(); break; case 'p': case 'P': state = 1 - state; break; // 暂停/继续 case 'a': case 'A': rotateBlock(); break; // 顺时针旋转 } } } }

方向键在控制台中有两个码值,第一个是224,第二个才是具体方向。这个细节记不住的话,按方向键时会发现程序毫无反应,因为_getch()把第一个224读走了,后面的方向值被当成下一次输入,逻辑就乱了。

很多人在输入处理上还会犯一个错误:用if而不是while包住_kbhit()。用户同时按了两个键,缓冲区里剩一个没读走,下一帧会重复响应,操作手感非常“飘”。用while循环一次性把缓冲区清空,这个体验问题就自然消失了。

3.3 渲染与闪烁问题

控制台渲染最简单的实现是每帧调用system("cls")清屏再重画全部内容。但实测结果是:画面疯狂闪烁,眼睛难受不说,还有游戏卡顿的错觉。更好的做法是用SetConsoleCursorPosition把光标定位到左上角,原地覆盖绘制全部画面,而不是清屏重画。这个函数在windows.h里,配合GetStdHandle使用:

#include <windows.h> void gotoxy(int x, int y) { COORD pos; pos.X = x; pos.Y = y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); } void drawGame() { gotoxy(0, 0); // 打印地图、活动方块、分数框 }

只定位不清屏的副作用是:方块移动后,原来的位置会残留旧画面。解决方法是每一帧完整重画整个游戏区域,由于所有格子都会被重新赋值,残留自然被覆盖。清屏不会真正解决问题,反而由于光标闪烁弄得更乱。

为了让画面更清晰,控制台输出方块我用两个字符,比如[],这样格子是正方形,视觉效果比单个字符好得多。空白位置输出两个空格。地图边框用##绘制。

高亮色可以用Windows控制台的颜色函数SetConsoleTextAttribute,背景色按方块类型区分,7种方块做成7种颜色。加上颜色后,游戏的可视性会上升一个台阶,代码成本只有几行。

3.4 下落计时器:让游戏节奏可调控

下落节奏的控制是另一个容易踩坑的环节。最简单的方法是用Sleep(1000)让程序每秒钟推进一次下落,但这会让按键响应也变慢。比如玩家在方块快落地时想按左键改变位置,程序正在休眠,按键得不到即时响应,等休眠结束可能已经来不及了。

正确做法是“每帧处理输入,按时钟决定是否下落”。一帧循环跑得非常快,但下落动作受时间控制。用clock()函数:

clock_t lastFallTime = clock(); int fallDelay = 1000; while (state != 2) { handleInput(); clock_t now = clock(); if (now - lastFallTime >= fallDelay) { if (!moveBlock(1, 0)) { mergeBlockToBoard(); int lines = clearLines(); score += getScore(lines); if (!spawnBlock()) { state = 2; // 新方块无法生成,游戏结束 } } lastFallTime = now; } drawGame(); }

moveBlock(1, 0)表示尝试向下移动一格。返回0说明没移动成功,也就是方块到了底部,这时候需要把活动方块合并进地图数组,检查消行,然后生成新方块。新方块生成后如果立即碰撞,说明游戏区域已满,游戏结束。

下落速度fallDelay随着等级升高递减,比如每清5行升一级,等级1时延迟1000ms,等级每升一级减少80ms,最低不低于150ms。这样游戏的难度曲线是渐进式的,不会突然快到没法玩。

3.5 合并地图、生成新方块、硬降落的实现

方块落底后,把活动方块形状里的所有1写入地图数组,坐标从当前原点偏移到全局位置。这里不用写独立函数再排序,写一个函数但逻辑要非常清晰:构造“临时地图”,把活动方块的内容复制到地图里,碰撞检测检查临时地图,而不是直接改真地图。

生成新方块:用rand()取0到6之间的数作为类型索引,让方块原点初始在第0行、第3列(居中)。方块生成后立刻做一次碰撞检测,如果碰撞说明没空间了,游戏结束。

硬降落(空格键)是玩家最常用的操作,实现思路很简单——循环调用moveBlock(1, 0),直到返回0为止。有硬降落体验马上不一样,不然7块堆满得等半天。

void hardDrop() { while (moveBlock(1, 0)); mergeBlockToBoard(); int lines = clearLines(); score += getScore(lines); if (!spawnBlock()) { state = 2; } }

硬降落和下落计时那个逻辑高度相似,代码重复是一眼能看出来的坏味道。比较好的做法是抽一个lockBlock()函数,统一处理“合并+消行+计分+生成新方块+检查结束”,下落计时和硬降落都调用它。

4. 常见问题与排查技巧实录

4.1 方块旋转穿模:旋转前先备份

旋转容易出的经典问题是“旋转后方块跑到地图外面去了”。比如I形方块竖着的时候靠近左边界,旋转成横着的,有一部分就穿墙了。好的做法是旋转前尝试几个偏移位置:先按原位置试,如果碰撞,分别尝试左移一格、右移一格,再判断结果是否合法。都不合法才放弃旋转。

void rotateWithKick() { int nextShape[4][4]; rotateClockwise(shapes[blockType], nextShape); int offsets[] = {0, -1, 1, -2, 2}; for (int i = 0; i < 5; i++) { if (!checkCollisionWithShape(row, col + offsets[i], nextShape)) { for (int a = 0; a < 4; a++) { for (int b = 0; b < 4; b++) { shapes[blockType][a][b] = nextShape[a][b]; } } col += offsets[i]; return; } } }

这里还需要特别提醒:旋转后的结果不要直接写回原形状数组。先旋转到一个临时数组,用临时数组做碰撞检测,检测通过了再备份回原数组,否则调试时一旦旋转失败,方块形状就已经被改掉了,很难恢复。

4.2 按下方向键移动一格却走了两格

原因通常有两个。一是没有用while循环读完整个键盘缓冲区,上一帧残留的按键被下一帧读到,连续移动就会“跳”一格。二是方向键和普通按键的处理顺序出了问题,特别是在处理方向键扩展码时,先读了一个224,紧接着读方向值,这中间如果添加了其他逻辑,就可能读到错误码。

还有一个更隐蔽的问题:如果_getch()读到的字符不是预定的方向键,它可能是一个普通的ASCII码,不做任何过滤就直接丢弃是对的。有的实现把读到的第二个码直接当成方向键处理,只要任意按键的扩展码是224,后续值就会被误解读,输入就会错乱。

4.3 游戏越玩越卡,循环速度在变慢

画面卡顿不一定是代码效率问题,多数时候是每次渲染时把整张地图的无意义字符也重新赋值一遍。比如已经固定不动的底层方块,每帧都重新画,地图越大性能越差。我自己写的时候做过一次对比:满屏幕重绘大概消耗几毫秒,对控制台游戏来说完全可承受,但如果把整个地图、边框、活动方块、界面信息全部混在一次渲染里,局部复杂度上升后会明显卡顿。

真正的卡顿原因大多是循环结构不合理。例如用了Sleep()做下落延时,导致按键响应延迟累积;又比如在一个函数内部,不小心写了一个循环,某个条件下i不减反增,造成死循环,游戏会卡在某个画面里不再响应。

排查卡顿的方法很简单:在drawGame()前后用clock()记录耗时,打印出来。如果单帧耗时超过50ms,就要考虑优化渲染策略了。

4.4 中文显示乱码与控制台编码问题

控制台默认代码页如果是GBK,打印中文会正常;如果系统是UTF-8,打印中文就会乱码。一个稳妥的办法是统一用英文字符做界面文字,绕开编码问题。如果坚持用中文,可以在程序开头调用SetConsoleOutputCP(CP_UTF8)或者system("chcp 65001")。注意这个系统调用在每个机器上的表现并不一致,我在测试时遇到过切换后字体变样的情况,代码里还是直接用纯英文提示最省心。

4.5 调试技巧:用“信息面板”定位问题

这个技巧我在很多项目里反复用,非常管用。在游戏画面右侧专门留一块区域,打印调试信息:当前分数、当前等级、当前活动方块的类型、原点坐标、暂停状态标志。这样每次出问题,先看信息和观察到的现象对不对得上。比如旋转后位置偏了,一眼就能从调试面板里看到原点坐标变化,再结合地图内容判断是哪一步算歪的。

调试面板本身也是一个游戏内可开关的选项,按住D键再动方向键切换显示。调试完的功能可以先留着,后续加功能时依然能派上用场。

4.6 摆放前先统一坐标混乱:局部坐标 vs 全局坐标

我敢说90%的同学写俄岁斯方块,代码出错都出在这里。方块自身用局部坐标,地图用全局坐标,两者之间的换算写错,视觉上就是方块位置完全对不上或者碰撞检测失灵。最好自己先拿草稿纸画一下:一个3x3的小地图,一个T形方块的局部格子坐标表,手动算一遍“原点在第2行第2列时,方块会占地图的哪些位置”。算明白了再写代码,会少走太多弯路。这个东西别看简单,一旦把坐标混淆的bug引入了,后期排查会非常痛苦。

5. 扩展思路与个人体会

这个项目的基础版本跑通后,我已经把它保存成自己的“练手模板”,时不时改改玩法思路,顺便温习C语言的基础细节。说几个后续可以继续加的功能:更高难度的方块预显示(下一块预览)、7-bag随机规则、输出到文本文件的重放、方向键长按加速下落。这些功能都不难加,却能让游戏的完整度和趣味性提升一个档次。

我个人在实际操作中的体会是:写俄岁斯方块这种项目,最重要的不是一开始就写得多完美,而是先跑起来,再逐步优化。第一版哪怕只有基础移动和手动下落,能玩起来就是一种成功。之后再慢慢补碰撞优化、旋转墙踢、渲染重绘、颜色区分这些细节。每改一个功能,记得把重新编译和测试的时间预留出来,一次只改一个点,别让多个改动纠缠在一起。

最后分享一个对新手很有用的小技巧:代码里加一个debugLog()的开关函数,需要用的时候在函数开头把日志写到一个文本文件里。控制台输出因为要实时刷新,打印过多会把画面搅乱,但日志文件不占画面空间,记录下完整的操作序列和状态变化,排查复杂问题时比看崩溃栈还直观。

C语言俄岁斯方块这个项目,代码量大概能控制在500行左右,短小精悍,却能覆盖数组、指针、函数、随机数、时间处理、非阻塞输入等一堆知识点。认真写完一遍,再去学数据结构、算法,思路都会开阔很多。动动手,跑起来,就输了第一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询