如果你学过C语言,一定在某个时刻冒出过“写个俄罗斯方块试试”的念头。这个看似简单的益智游戏,其实是C语言里最经典的综合性项目之一:数组、指针、结构体、函数、循环、随机数、键盘输入、文件读写,全都用得上。更重要的是,它不像刷题那样只考察某个孤立知识点,而是逼着你思考:一个实时游戏的逻辑闭环到底该怎么搭。我见过太多人背了一堆语法,却在面对“方块下落、消行、再生成”这种连续事件时完全无从下手,这个项目就是把C语言知识变成真正程序能力的最佳跳板。
这篇文章我会以控制台版本为主线,从架构设计讲到旋转算法,再到我实际踩过的几个坑,完整走一遍C语言实现俄罗斯方块的思路。控制台版不依赖图形库,只需要标准C或者极少量的系统API,打完就能跑,环境配置成本最低。无论你是准备计算机二级的考生、正在学指针和二维数组的大学生,还是想练嵌入式C基本功的开发者,这篇文章都值得看完。我会把代码关键片段拆开讲透,尤其会解释为什么要这样设计,而不是给你几百行代码让你自己猜。
1. 为什么说俄罗斯方块是C语言最好的综合训练
1.1 一个项目覆盖大半本教材
你翻任何一本C语言教材,目录不外乎变量、分支、循环、数组、函数、指针、结构体、文件操作。俄罗斯方块这个项目几乎把这些章节串成了一条线:地图是一个二维数组,方块旋转需要索引计算,游戏状态需要结构体管理,方块生成和随机序列有关,键盘输入需要处理缓冲,存档功能涉及文件读写。一个项目做完,你等于把所有语法点重新用了一遍,而且是用在真实问题上,不是背例题。
很多初学者会问:我刷了上百道题,为什么还是不会写程序?原因很简单,练习题通常是“给你输入求输出”,而游戏是“在任意时刻都可能发生输入、计时、碰撞、渲染”的实时系统。两个思考模式完全不同。俄罗斯方块正好能把思维从“单向执行”扭转成“事件驱动”,这个思维转换对后续学嵌入式、学游戏开发、甚至学网络编程都特别关键。
1.2 游戏循环的本质:一个永不停歇的状态机
俄罗斯方块抛开外观,本质是三个问题的循环:什么时刻该发生什么、发生了之后怎么更新、更新之后怎么画出来。用专业一点的话说,这就是游戏主循环,本质上是一个状态机。你要管理“游戏中、暂停、结束”这些状态,还要管理“方块是否落在底部、是否消行、是否新方块出生”这些事件。
当你在C语言里亲手实现这个循环,你会自然理解一个非常重要的原则:一个程序不能同时忙两件事,但可以通过快速轮询和状态切换让所有事看起来在同时发生。这个道理放到后面的线程、中断、事件驱动编程里都通用。所以说俄罗斯方块不只是“游戏”,它是理解程序如何与外部世界打交道的启蒙课。
1.3 先画模块图再写代码,省下十倍调试时间
我自己的习惯是:写任何超过200行的C程序之前,强制自己在纸上画模块关系。俄罗斯方块拆开大概有这些模块:地图模块、方块模块、碰撞检测模块、旋转模块、消行模块、输入模块、渲染模块、游戏控制模块。每个模块只有一两件事要做,模块之间通过接口通信,比如“碰撞检测”只负责回答“这个方块放在这里是否合法”,至于该不该放置,那是游戏控制模块的事。
很多新手喜欢把所有逻辑塞进main函数里,写到后面自己都看不懂。做这个项目最好的开始方式,不是打开IDE直接敲代码,而是先回答四个问题:地图用什么数据结构存?方块用什么数据结构存?怎么判断方块能不能移动?消行之后数据怎么更新?这四个问题想清楚,项目基本成功了一半。接下来的几节我会逐个说清楚。
2. 方案选型:控制台、Windows API、EasyX到底选哪个
2.1 三种实现方案的对比
做俄罗斯方块的图形方式有很多,网上也见过俄罗斯方块C++高清版、Unity版之类的,但C语言最主流的还是这三种:
| 方案 | 渲染方式 | 依赖 | 优点 | 缺点 |
|---|---|---|---|---|
| 控制台字符版 | printf输出/光标定位 | 标准C | 跨平台、逻辑清晰、适合学习 | 画面简陋 |
| Windows API版 | GDI窗口绘制 | Windows SDK | 可做到完整游戏界面、支持鼠标 | 代码量大、API繁琐 |
| EasyX图形库版 | 图形API封装 | EasyX库 | 上手快、绘图函数简单 | 只支持Windows、隐藏了底层细节 |
我强烈建议第一次做的时候选控制台字符版,哪怕你最终目标是做图形版本。原因很简单:控制台版能逼你把所有游戏逻辑写到最简。方块本质是二维数组,地图本质是二维数组,屏幕输出也不过是把数组打出来。当你能用纯逻辑把游戏跑通,后面换图形界面,只是把“printf一个字符”换成“绘制一个色块”,核心代码基本不用动。直接上EasyX或Windows API,很多精力会耗在处理窗口消息、重绘这些和游戏逻辑无关的事情上,容易捡了芝麻丢西瓜。
2.2 环境准备:VS和VSCode怎么配C环境
如果你用Visual Studio,新建一个“空项目”,添加一个.c文件就能直接跑。如果你用VSCode,核心是安装C/C++扩展,然后配置MinGW或者MSVC编译器,路径设置好之后通过“运行和调试”即可。初学者最常卡在环境变量上:gcc命令能用,但是“终端无法识别”或者“头文件找不到”。解决办法是确认编译器的bin目录已经加入系统PATH,再打开一个新的终端验证gcc --version。
这里顺便说一句,网上不少教程让装各种插件,其实写控制台游戏用不到那么多工具链。我们只需要三样东西:编译器、文本编辑器、一个能跑命令的终端。等调试的时候你自然会需要调试器,但起步阶段完全可以靠printf打印关键变量来观察问题。我见过有人花了两天配置环境,代码一行没写,这是本末倒置。
2.3 本文采用的工程结构
我用的是控制台字符版,同时为了画面刷新不闪屏,会用到Windows的SetConsoleCursorPosition。如果你在Linux的终端里跑,可以用ANSI转义序列\033[H替代,或者直接每次清屏重画,代码稍作修改即可。整个工程我分成四个文件:
tetris.h:声明数据结构、全局变量、函数接口game.c:游戏控制逻辑,主循环、下落、消行、结束判断shape.c:方块定义、旋转、碰撞检测main.c:初始化、启动主循环、退出清理
这个结构和商业项目当然没法比,但对学习来说足够优雅,每个文件能独立编译,最后链接到一起。这种“头文件声明、源文件实现”的分离方式,也是C语言工程的基本功,许多初学者只在教材上见过printf.h,从来没自己拆过模块,正好借这个项目练手。
3. 数据结构定生死:如何用二维数组装下一个游戏
3.1 地图:加一圈“墙”让碰撞检测变简单
俄罗斯方块标准棋盘是10列20行。最直接的做法是定义一个[20][10]的二维数组,然后用0表示空格、1表示方块。这个方案能跑,但每次判断边界都要写类似x >= 0 && x < 10 && y < 20的表达式,写多了特别容易漏。
我的做法是定义成[22][12],四周多出一圈墙:
#define ROWS 20 #define COLS 10 // 地图包含四周墙壁 int map[ROWS + 2][COLS + 2]; void init_map() { for (int i = 0; i < ROWS + 2; i++) { for (int j = 0; j < COLS + 2; j++) { if (i == 0 || i == ROWS + 1 || j == 0 || j == COLS + 1) { map[i][j] = 1; // 墙壁 } else { map[i][j] = 0; // 游戏区域 } } } }这样做的妙处在于:碰撞检测完全不需要管边界条件,只要看目标位置的map值是否为1。墙是1,方块是1,空格是0,统一判断即可。这个技巧有点像给数组加哨兵,简化边界处理,在很多算法题里也用得上。
3.2 方块形状:用四维数组存储所有旋转状态
俄罗斯方块有七种标准方块:I、O、T、S、Z、J、L。为了统一逻辑,每种方块我都用4x4矩阵承载。为什么是4x4?因为I横着有4格,4x4矩阵可以装下所有形状的所有旋转形态,而且旋转前后移动规律一致。
不同方块需要的旋转状态数量不一样:O方块不管怎么转都一样,1种就够;I横竖两种;其他方块理论上是4种。为了代码统一,我直接用三维数组[7][4][4][4],把每种方块的4个旋转形态全部预计算并写进去,哪怕O的4个形态一模一样也无所谓。这样做牺牲一点内存换来了极低的代码复杂度,运行时完全不需要临时计算旋转矩阵。
来看部分定义:
// SHAPES[形状编号][旋转状态][4x4坐标] const int SHAPES[7][4][4][4] = { // O 方块 { { {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, // 其余三个状态复制即可 }, // 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} }, // 其余两个状态自行补上 }, // 其他 T、S、Z、J、L 同理 };很多教程教“运行时旋转矩阵”,比如通过公式tmp[c][3-r] = shape[r][c]来顺时针旋转。这本身没错,但实现时要处理旋转中心和是否越界,并且容易出bug。我后来在工程实践中更喜欢直接预定义所有旋转状态,因为俄罗斯方块的旋转表是固定的,预计算不仅省去临时算矩阵的开销,还能手动调整某些形态使它更符合手感。这种预计算策略在处理固定规则时比运行时计算可靠得多。
注意,定义旋转状态时要保证每种方块有一个“逻辑中心”,比如T方块的旋转通常绕中心格转。4x4矩阵里用空行空列调整位置,保证旋转前后方块不会因为坐标系问题跳出一个身位。这是很多初学者容易忽视的细节,后文会单独讲。
3.3 当前方块结构体:把类型、坐标、旋转状态捆在一起
我的当前方块是这样定义的:
typedef struct { int x; // 方块矩阵左上角在地图上的x坐标 int y; // 方块矩阵左上角在地图上的y坐标 int type; // 方块类型 0~6 int rotation; // 旋转状态 0~3 } ActiveBlock;有人会问,为什么不用绝对坐标直接存每个小方格的位置?因为用“矩阵左上角坐标 + 旋转状态”只需要维护三个变量,每次移动或旋转就改坐标和状态,然后再去SHAPES里查具体格子分布,逻辑非常清晰。反过来如果直接存四个格子的坐标,移动时要循环四个格子分别加1,旋转时要重新计算四个格子的相对位置,代码又臭又长。
这里有一个隐藏的关键点:方块初始位置怎么定。标准俄罗斯方块出生点在上方中间,但棋盘顶部之上也要允许方块存在,否则方块还没有完全进入棋盘就无法生成。我的做法是让方块的矩阵左上角初始坐标为COLS / 2 - 1和-1。注意这里的y可以是负数,碰撞检测里遇到负数坐标只需要判断它对应的map下标是否越界。我的地图加了墙,所以map[y + r][x + c]直接用就行,只要矩阵里有1的格子落在地图内部才算数,在墙外、天花板外都不算碰撞。这个“允许负坐标出生”的设计能避免新方块一出场就判定game over的尴尬。
3.4 全局状态:用枚举管住游戏阶段
一个完整的俄罗斯方块有多个阶段,我用枚举来管理:
typedef enum { STATE_MENU, // 菜单 STATE_PLAYING, // 游戏中 STATE_PAUSE, // 暂停 STATE_GAME_OVER // 结束 } GameState;再用一个结构体把分数、消除行数、当前等级、当前方块、下一方块打包起来,作为游戏全局上下文。这样设计有两个好处:一是主循环里只需要根据状态分发到不同逻辑;二是以后如果要加存档功能,只需要把这个结构体整体读写即可,不用满世界找变量。
4. 旋转、碰撞与消行:三个最难啃的骨头
4.1 碰撞检测:判断“能不能动”的唯一标准
碰撞检测是俄罗斯方块的地基,所有移动、旋转、落定都要用它。思路非常直接:遍历当前4x4矩阵,凡是值为1的格子,映射到地图坐标,检查对应位置是不是已经被占用。占用的意思包括撞墙和撞已有方块。
int check_collision(ActiveBlock *block) { const int (*shape)[4] = SHAPES[block->type][block->rotation]; for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (shape[r][c]) { int mx = block->x + c; int my = block->y + r; if (mx < 0 || mx >= COLS + 2 || my < 0 || my >= ROWS + 2) { return 1; // 超出地图边界视为碰撞 } if (map[my][mx]) { return 1; } } } } return 0; }因为地图我加了墙壁,正常情况下mx和my不会被墙壁外的坐标弄越界,但为了防御式编程我仍然保留了范围检查。一个容易忽略的坑是:当方块还在天花板之上时,my可能是负数,访问map[my][mx]是非法的,所以必须先检查my < 0并在此时放行。这个临界情况如果处理不好,方块刚下落一秒就莫名其妙game over。
碰撞检测用完后,移动的逻辑就很简单了:先把方块坐标改成尝试位置,然后检测碰撞,如果碰撞就回滚。先改再检测、撞了再回退,这个模式在游戏里很常见,比“先计算合法位置再过去”要简洁。
4.2 旋转算法:为什么我劝你别实时旋转,直接查表
实时旋转矩阵用到的算法不复杂,但俄罗斯方块有个特殊的“踢墙”问题。假如一个方块靠近左墙,原地旋转后有一部分会嵌进墙里,严格按碰撞检测来说这就是非法旋转,玩家会感觉“转不动”,体验很差。标准做法是允许方块在旋转时自动平移一点,尝试几个偏移位置,比如先试原地转,不行就往左挪一格试试,再往右挪一格试试,再往上挪一格试试,只要有任何一个位置能放下,就转过去。
踢墙逻辑实现:
int try_rotate(ActiveBlock *block) { int old_rotation = block->rotation; int next_rotation = (block->rotation + 1) % 4; int offsets[] = {0, -1, 1, -2, 2}; // 尝试的横向偏移 block->rotation = next_rotation; for (int i = 0; i < 5; i++) { block->x += offsets[i]; if (!check_collision(block)) { return 1; } block->x -= offsets[i]; } // 全部失败则回退 block->rotation = old_rotation; return 0; }这套策略是简化版的Super Rotation System(SRS),虽然不是任天堂原版那么完整,但手感已经足够顺滑。很多网络上的俄罗斯方块C语言代码没有踢墙逻辑,方块贴墙就转不了,玩起来很生硬,原因就是没做这种偏移尝试。
我前面提议预定义旋转表,最大的优势在踢墙阶段也体现出来了:因为所有旋转形态是写死的,你完全可以在定义形状时就手动把“旋转后位置”调整到最舒服的状态,而不是用数学公式硬转出一些反直觉的结果,最后还得写更多特判代码去修正。
4.3 消行:从满行检测到下移,一段自顶向下的循环
消行算法本质是:从底部往上扫每一行,如果一个格子都没空,就说明满行,删除它并让上面的都往下掉一行。
int clear_lines() { int lines = 0; for (int r = ROWS; r >= 1; r--) { int full = 1; for (int c = 1; c <= COLS; c++) { if (map[r][c] == 0) { full = 0; break; } } if (full) { lines++; for (int rr = r; rr > 1; rr--) { for (int c = 1; c <= COLS; c++) { map[rr][c] = map[rr - 1][c]; } } for (int c = 1; c <= COLS; c++) { map[1][c] = 0; } r++; // 因为下面一行又被挪上来,需要重新检查当前行 } } return lines; }这里有个细节很多人会写错:删除当前满行之后,上面所有行都要往下移一行,但是移动完以后,原来位于“满行上方一行”的内容到了“满行”的位置,它可能也是满的,所以要把行指针重新拉回来,也就是r++配合循环里的r--,等于再检查一次当前位置。如果你漏了这句,连续多行同时消掉的时候就只消掉一行,分数就少了,长时间玩下来累计差一大截。
计分规则我采用经典规则:
| 一次性消行数 | 得分 |
|---|---|
| 1行 | 100 |
| 2行 | 300 |
| 3行 | 500 |
| 4行 | 800 |
这个非线性设计是为了鼓励玩家追求Tetris(一次消四行)。实际写代码时,把这个规则做成一张表,用索引取分,比一堆if else清晰得多。
消行之后还要把分数加到全局变量里,同时检查消行总数来判断是否升级加速。等级提升意味着下落间隔缩短,这正好引出下一节的主循环设计。
5. 游戏主循环:如何让方块自己掉下来,又保证键盘响应
5.1 帧循环:为什么不能用Sleep来驱动下落
最直观的下落做法是:每循环一次就Sleep(500),然后执行下落。表面看成立,实际会导致一个严重问题:Sleep会阻塞整个程序,在睡眠期间玩家按键完全无响应,体验非常糟糕。正确做法是用一个非阻塞的计时器,每次循环都检查时钟,看距离上次下落是否已经过去了指定毫秒数,如果到了才执行下落。
#include <time.h> clock_t last_drop = clock(); int drop_interval = 500; // 毫秒,等级越高越小 while (state == STATE_PLAYING) { // 处理输入 if (kbhit()) { handle_input(); } // 检查是否该下落 if (clock() - last_drop >= drop_interval) { move_down(); last_drop = clock(); } // 渲染 render(); }用clock()计时是跨平台写法,Windows平台也可以用GetTickCount()或GetTickCount64()。这段代码的核心逻辑是:每帧都很快地循环一次,但“下落”只在时间到的时候执行。这样既保持了画面刷新和键盘响应,又控制了方块下落速度。
5.2 下落、落定、生成新方块:一条环环相扣的链
下落逻辑是:试图把方块y+1,看看能不能放下。如果放得下就更新坐标;放不下说明方块触底或压到了其他方块,此时要把方块“写入”地图,然后检查消行,最后生成下一个方块。
void move_down() { ActiveBlock tmp = *current; tmp.y++; if (!check_collision(&tmp)) { current->y++; } else { lock_block(); // 写入地图 int removed = clear_lines(); add_score(removed); spawn_next_block(); // 生成新方块 if (check_collision(current)) { state = STATE_GAME_OVER; } } }注意生成新方块之后的那个碰撞判断:如果新方块出生点立刻撞上已有方块,说明棋盘堆满了,游戏结束。这个判断要放在出生后马上做,而且要放在渲染之前。
5.3 输入处理:方向键、暂停键和“吃掉”两个字符的问题
控制台游戏输入用的是_kbhit()和_getch(),前者检测键盘有没有按下,后者获取按键字符。方向键不是普通ASCII字符,在Windows控制台按下方向键会返回两个字节,第一个可能是0xE0或0x00,第二个才是真正的键码。很多人只调一次_getch(),结果拿到的是0xE0,剩下那个键码留在缓冲区里,导致按一次方向键要等第二次才生效。
int get_direction_key() { int first = _getch(); if (first == 0xE0 || first == 0) { return _getch(); // 方向键扫描码 } return first; }扫描码对应:72上、75左、77右、80下。另外空格键用来硬降,P键暂停,回车确认。处理输入时要注意用switch-case分流,并且要根据当前游戏状态决定按键是否生效,比如暂停状态下方向键就不该触发移动。
5.4 渲染:别用system("cls"),用光标定位重绘
不少初学者用system("cls")清屏后重画全图,屏幕会明显闪烁。原因很简单:清屏和绘制是两个操作,中间有一段时间屏幕上没有内容或没有完整内容。更好的办法是每次把光标定位到窗口左上角,然后重新覆盖输出所有内容,不清屏。Windows下用SetConsoleCursorPosition:
void gotoxy(int x, int y) { COORD pos = {(SHORT)x, (SHORT)y}; HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); SetConsoleCursorPosition(hOut, pos); } void render() { gotoxy(0, 0); // 打印地图和当前方块、下一方块、分数、等级 }把光标移回(0,0)再重绘,因为新内容会逐个覆盖旧内容,肉眼几乎感知不到闪烁。如果你用的Linux终端,可以用printf("\033[H")清屏并把光标移回原点,效果类似。顺便可以用SetConsoleTextAttribute给不同方块上色,七种形状不同颜色玩起来舒服很多,这个函数在Windows上也只需要几行代码。
6. 实际开发中我踩过的4个坑
6.1 数组越界:程序崩溃但不知道崩在哪
第一次写碰撞检测时,我没考虑方块在出生点y坐标为负的情况,直接在map[y + r][x + c]处取下标。当方块从顶部出现、矩阵有1的格子位于天花板外,map的负下标访问会把内存里的垃圾数据当成地图内容,轻则方块不能下落,重则直接段错误。后来我在每个取下标前都先打印mx和my,才发现出生帧就会有负y。
解决这个问题有两条路:一是像我前面说的一样,碰撞检测里对my < 0放行;二是地图在上方额外加几行虚空,专门给方块生成用,代价是要处理“显示但不参与碰撞”的格子。我选第一种,代码最直接。建议所有做地图类游戏的读者,遇到“奇怪崩溃”时先怀疑数组越界,用printf把关键下标打出来,不要盯着代码猜。
6.2 方向键按下没反应,按两下才动一下
这就是前面说的双字节扫描码问题。我第一次用_getch()处理上下左右,只调用了一次,拿到的总是0xE0,导致方向键看起来完全失灵,而字母键正常工作。查了很久才明白Windows控制台方向键的编码规律。解决后我在_getch()最外层加了一个包装函数,确保方向键和字母键都能统一返回一个简单的枚举值。这个坑在网络上的俄罗斯方块C语言代码里也特别常见,如果你也遇到键盘响应错乱,优先检查这里。
6.3 旋转踢墙导致方块卡进墙里
我最初的旋转代码只检测“旋转后位置合不合法”,却没有尝试横向偏移,结果方块靠墙时旋转会直接被判定为非法,手感生硬。加入踢墙逻辑后又犯了一个错误:只尝试了左右各1格的偏移,方块在一个“一字型”沟槽里时,I方块竖起来需要横移2格才放得下,于是旋转后照样碰撞。后来把偏移量调成{0, -1, 1, -2, 2},问题解决。
这个经验告诉我,旋转判定不能只看一个目标位置,要看作“一个集合”里有没有合法位置。很多官方规则里甚至要尝试5种偏移和多次上移,这套思路完全可以抽象成通用的“替换候选位置函数”,别和旋转逻辑写死捆绑。
6.4 游戏画面闪烁到看不清
早期版本用system("cls"),方块下落时整个屏幕一闪一闪,连形状都看不出。后来改成光标定位重绘,才明白问题不在于绘制效率,而在于清屏动作本身破坏了画面的连续性。这里也建议把地图绘制和方块位置分开处理:先画静态地图,再把当前方块叠加上去,最后画UI。这样即使方块坐标改变,重绘也只需覆盖局部,既减少闪烁也为将来扩展“幽灵方块”功能打下基础。
下面用一张表总结这几个坑的根因和对应解法:
| 现象 | 根本原因 | 快速定位方法 | 修复方案 |
|---|---|---|---|
| 程序崩溃 | 数组负下标越界 | 打印关键下标 | 碰撞检测对负y放行 |
| 方向键失灵 | 双字节扫描码未取出 | 打印_getch返回值 | 包装输入函数 |
| 旋转不顺 | 缺少踢墙偏移 | 靠近墙壁测试旋转 | 多候选位置尝试 |
| 画面闪烁 | 每次清屏重绘 | 观察刷新频率 | 光标定位覆盖重绘 |
7. 可以继续加的功能:从能玩到好玩再到有点AI
7.1 存档和最高分:文件读写的那点事
俄罗斯方块做完基础功能,最值得加的第一个扩展就是最高分持久化。C语言文件读写在这里刚好派上用场,fprintf和fscanf就能搞定最简单的文本存档。保存的内容至少包含:最高分、总共消行数、当前游戏等级。游戏初始化时读文件,游戏结束时再写回。注意处理文件不存在的边界情况,第一次启动时没有存档文件,读文件失败不能直接崩溃,要默认成0分。
文本存档的另一个好处是调试方便,你可以直接打开文件检查内容,确认读写逻辑有没有问题。进阶一点可以用二进制格式保存结构体,用fwrite和fread,但二进制文件不能直接阅读,调试时相对麻烦。
7.2 下一个方块预览和幽灵方块
“下一个方块”预览实现起来非常简单:只需要在全局变量里维护一个next_shape,在生成新方块时把它复制给当前方块,再随机生成下一个。渲染时在棋盘右侧把SHAPES[next_shape][0]打印出来即可。这项功能几乎不消耗性能,对玩家帮助却很大,能提前规划摆放位置。
幽灵方块(落地位置预览)稍微复杂一点:从当前坐标开始,把y一直往下加到最底,直到碰撞为止,然后把这条“虚拟路径”的最终位置用浅色或轮廓画出来。这个功能在训练玩家预判能力时很好用,也是后续做AI的雏形。
7.3 一个简单AI:让程序自己玩俄罗斯方块
这是我最推荐的进阶玩法,因为学习量非常大。写一个AI本质上就是写一个“评判函数”:给定一个局面,评估它的好坏,然后找出所有可能的放置位置,选分数最高的一个。评估维度常见的有四个:下一步消行数、堆叠高度、凹凸不平度、洞的数量。权重调一下,AI的智商就完全不同。
具体实现方法是:让方块在每次下落时暂停,枚举所有旋转状态和所有水平位置x,逐个模拟“落到最低点”,然后评估放置后的地图。这个过程中你可以顺便复用前文写好的碰撞检测和消行函数,体会模块化设计的好处。很多C语言教程不会告诉你,游戏AI“暴力枚举+评估函数”的思路比“规则判断”靠谱得多,而且写成代码并不复杂。
我自己的建议是,先把“下一个方块预览”和“幽灵方块”做完,再尝试AI。因为前两个功能能让你的游戏体验完整度上一个台阶,第三个功能则让你从“写游戏的人”变成“优化算法的人”,这是两种完全不同的能力。
做这个项目时我对三个经验印象最深。第一,代码不是越长越好,俄罗斯方块核心逻辑几百行就能写完,关键是每个函数职责清楚。第二,很多看似“程序跑不起来”的问题,根源都是数据设计不够好,比如地图不加墙、方块不用统一矩阵结构,后面各种特判和bug都会找上门。最后,如果你卡在某一步,最快的方法是手动模拟几组数据,用笔在纸上画出方块和地图,然后对照代码走一遍,找bug效率比在编译器里单步调试高多了。