简介:基于Windows API和C语言实现的俄罗斯方块小游戏,面向具备基础C语言语法、希望了解Win32程序框架与游戏循环的初学者,也适合作为课程设计或毕业设计的参考项目。压缩包共28个文件、约1.12MB,包含game3.cpp、function.cpp等核心源码,配合头文件、图标及资源脚本,可在VC++6.0下直接打开工程运行。代码均附注释,覆盖窗口创建、键盘消息处理、方块变形与碰撞检测等关键环节,并实现了左右键控制方向、下键加速、上键变形、空格暂停的完整操控。已有4512人学习下载,适合用来拆解Windows编程从消息循环到绘图刷新的实现思路,也可在此基础上自行扩展计分、关卡与音效功能。 把俄罗斯方块用纯C写一遍,是我认为C语言学习路径里性价比很高的一个控制台实战项目。我用VS2022写过一个能在Windows和Linux下编译的版本,面板20行10列,七种方块、旋转、加速、消行、计分、逐级提速一应俱全,源码总量压到500行以内。这篇文章就把这个项目从数据结构到玩法循环完整拆开,附上关键源码,适合已经学完数组、循环、函数、想找个项目练手的人,也适合想快速复习C语言核心知识的人。
这个项目能学到的东西很实在:二维数组的使用和边界处理、状态机的设计思路、平台相关的键盘读取、定时循环的写法,以及“逻辑和渲染分离”的代码组织方式。这些知识点单独看都不难,但组合在一起就容易写出各种诡异bug。文章里我会把每一步“为什么这样设计”也说清楚,而不是只丢一个能跑的源码。
我强烈建议你不要直接复制完就编译看效果,而是先跟着思路把关键模块亲手敲一遍。源码后面加功能、修bug都会顺手得多。
1. 项目定位与整体架构:先想清楚再动手
1.1 俄罗斯方块拆开看:一个状态机
俄罗斯方块表面上是图形画面在变,实际上是一个状态机的循环。每个方块从生成、移动、旋转、下落、固定到被消行清除,就是一次完整的生命周期,游戏只是把这个生命周期不断重复。编程时如果你直接写“让画面动一下”的代码,很快就会失控;如果你先划分状态,再写对应处理函数,逻辑会清爽很多。
我给初学者讲架构时,喜欢把一条主循环简写成这样:读输入、更新逻辑、渲染画面,三步反复执行。输入包括左右移动、旋转、加速下落;更新逻辑包括检测碰撞、固定方块、消行计分;渲染则是把面板和活动方块画到控制台。每一步都对应一个独立函数,后期修bug时可以直接定位,不用在几百行代码里翻来找去。
1.2 为什么用控制台而不是图形库?
这个项目我刻意不用任何图形库,原因很简单:C语言核心知识在于内存、数组、指针和流程控制,俄罗斯方块的难点也全在这几个点上。如果一开始就挂上EasyX或SDL,重点会被拉到窗口初始化和图像绘制上,反而忽略真正的数据结构设计。控制台版虽然“长得简陋”,但玩法闭环完全能跑通。
等这个版本做完,你对二维数组、状态机、平台相关API有了基本手感,再去接图形库就是水到渠成的事。换成图形库后,核心逻辑一行都不用改,只需要把渲染函数换掉就行,这种“逻辑与渲染分离”的结构才是这个项目真正值钱的地方。
1.3 模块划分:单文件也能优雅
为了降低编译门槛,我不建议新手直接拆多个.c文件,单文件分段就够了。我实际使用的分段顺序是:宏定义和全局变量、方块数据表、工具函数、键盘输入、碰撞与消行逻辑、渲染、主循环。这样从数据到逻辑再到显示,正好对应游戏运行时的处理顺序。
后面想学习模块化时,再把这些分段拆成board.h、block.c这些文件也很快,因为函数边界已经划清了。我自己的经验是:先单文件跑通,再拆文件,比一开始就搞工程结构要省心得多。
2. 数据结构设计:方块表和面板是核心
2.1 面板只用二维数组
游戏面板是一个20行10列的网格,最直接的就是二维数组。我定义:
#define ROW 20 #define COL 10 int board[ROW][COL]; // 0 表示空格,非 0 表示固定方块有人会在数组外面再加一圈“墙”,把数组变成22x12,这样越界判断会被挡住,写起来很舒服。但我的课程版本没有加墙,因为加墙后行列下标容易绕晕新人。实际代码里,碰撞判断必须处理边界:
int out_of_bounds(int x, int y) { return x < 0 || x >= COL || y >= ROW; }注意是>=,不是>。很多刚写数组的人在这里犯错,越界后程序行为会变得很诡异,而且这类bug很难直接看出来。
2.2 七种方块的坐标表示
每种方块我拆成四个小方块坐标,以方块左上角为参考点,定义四种旋转状态。先定义活动方块的结构体:
typedef struct { int type; // 方块种类 0~6 int rotation; // 旋转状态 0~3 int px, py; // 方块左上角在面板中的位置 } Block;实际方块形状放在一个三维常量表里。每一层代表一种方块,每个方块四个旋转状态,每个旋转状态有四个相对坐标。以I方块为例,完整写出来是这个样子:
static const int shapes[7][4][4][2] = { /* I */ { {{0,0},{1,0},{2,0},{3,0}}, {{1,0},{1,1},{1,2},{1,3}}, {{0,0},{1,0},{2,0},{3,0}}, {{1,0},{1,1},{1,2},{1,3}} }, /* O */ { {{0,0},{1,0},{0,1},{1,1}}, {{0,0},{1,0},{0,1},{1,1}}, {{0,0},{1,0},{0,1},{1,1}}, {{0,0},{1,0},{0,1},{1,1}} }, /* T, S, Z, J, L 照此扩展 */ };注意,I和O方块旋转后坐标有的会重复,这是正常现象,照实写就行。把7种方块的初始状态列出来,方便对照:
| 类型 | 名称 | 常见初始坐标 |
|---|---|---|
| 0 | I | (0,0)(1,0)(2,0)(3,0) |
| 1 | O | (0,0)(1,0)(0,1)(1,1) |
| 2 | T | (0,0)(1,0)(2,0)(1,1) |
| 3 | S | (1,0)(2,0)(0,1)(1,1) |
| 4 | Z | (0,0)(1,0)(1,1)(2,1) |
| 5 | J | (0,0)(0,1)(1,1)(2,1) |
| 6 | L | (0,1)(1,1)(2,1)(2,0) |
这种写法看似笨,但好处是旋转状态是写死的,永远不用临时算坐标,也不会算错。等你有经验后,再改用旋转矩阵生成也行。
2.3 方块旋转与“踢墙”
旋转不是简单把rotation加1,还要判断新位置会不会碰撞。如果碰撞,我会尝试左移一格、右移一格,再不行就放弃本次旋转。这几次尝试就是所谓“踢墙”,没有它,靠墙的方块转一下就直接卡住,体验很差。
int try_rotate(Block *b, int board[ROW][COL]) { int oldr = b->rotation; int r = (b->rotation + 1) % 4; int offsets[] = {0, -1, 1}; // 先原位,再左右各试一次 for (int i = 0; i < 3; i++) { b->px += offsets[i]; b->rotation = r; if (!hit(b, board)) return 1; b->px -= offsets[i]; } b->rotation = oldr; return 0; }先尝试新旋转状态下的原位,如果撞了,左移一格看行不行,还不行再右移一格。只有这几次尝试都失败才回滚。这个细节直接决定游戏“顺不顺手”,很多人没做踢墙,方块一到墙边就转不动,其实就是差这两行代码。
3. 核心逻辑实操:从按键到消行一次说透
3.1 非阻塞键盘输入
控制台程序默认读取键盘是阻塞的,按下回车才返回,这对游戏完全不能用。Windows环境下用<conio.h>里的_kbhit和_getch:
if (_kbhit()) { int key = _getch(); if (key == 224) key = _getch(); // 方向键是两个字节,再读一次 switch (key) { case 75: /* 左移 */ break; case 77: /* 右移 */ break; case 80: /* 软降 */ break; case 72: /* 旋转 */ break; } }方向键具体键值在不同编译器下可能不同,调试时可以先把key打印出来看一眼。这也是我强烈推荐的通用做法:别猜,先打印。Linux环境下没有conio,需要把终端切到“原始模式”,用termios把回显和行缓冲关掉,读取后还要恢复终端设置,代码稍长但原理一样。
3.2 下落计时与难度加速
下落不能靠死循环里的“延时”,更好的做法是记录时间戳,每隔固定间隔执行一次下落。我用毫秒级的时间函数,Windows下用GetTickCount64,Linux下用clock()换算,这里统一封装成一个now_ms():
unsigned int now_ms() { return (unsigned int)(clock() * 1000 / CLOCKS_PER_SEC); }然后根据关卡计算下落间隔:
int interval = 500 - (level - 1) * 50; if (interval < 100) interval = 100; if (now_ms() - last >= interval) { last = now_ms(); /* 尝试下落,失败则固定、消行、生成新方块 */ }初始速度取500ms下落一格,每升一级减50ms,下限100ms。这个参数不是拍脑袋,500ms能给新手足够的反应时间,后面逐级提速也让难度曲线平滑。
3.3 碰撞判定、固定与消行
碰撞判定是整个项目的核心函数:
提示:这段代码是整个游戏的“心脏”,改之前一定要把x/y的含义写在注释里,否则调试时会浪费大量时间。
int hit(Block *b, int board[ROW][COL]) { for (int i = 0; i < 4; i++) { int x = b->px + shapes[b->type][b->rotation][i][0]; int y = b->py + shapes[b->type][b->rotation][i][1]; if (x < 0 || x >= COL || y >= ROW) return 1; if (y >= 0 && board[y][x]) return 1; } return 0; }注意y可能暂时为负数,因为新方块刚出现在面板上方时,py可能小于0。只有y >= 0时才检查面板数据,否则会越界访问。我见过很多初学者在这里直接写board[y][x],程序偶尔崩溃却找不到原因。
固定方块就是把四个坐标写进board,然后调用消行函数。消行最直接的写法是从下往上扫描每一行,全满就把它上面的所有行整体下移一行,然后把最上面一行清空。注意消行后同一行还要再检查一次,因为上面可能有行又补下来了,所以循环里不要立刻递增行号:
int clear_lines(int board[ROW][COL]) { int cleared = 0; for (int i = ROW - 1; i >= 0; ) { int full = 1; for (int j = 0; j < COL; j++) if (!board[i][j]) { 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; cleared++; } else { i--; } } return cleared; }这段代码看着简单,但边界很容易错。数组下标从0到19,移动行时如果写成k <= ROW就会越界;从下往上消行能保证处理多行时不会漏行。清除行数返回后,再乘上不同得分系数,就实现了计分。
3.4 主循环骨架
把这些拼起来,主循环就是:
while (!gameover) { if (_kbhit()) { /* 处理一次按键输入 */ } unsigned int now = now_ms(); if (now - last >= interval) { /* 更新逻辑:下落/固定/消行/生成 */ } render(); Sleep(10); }我习惯把输入、逻辑更新、渲染写成三个独立函数,主循环只做调度。这样命令行模式下测试逻辑也方便,直接调用更新函数就能驱动一帧。
4. 渲染与控制台体验优化
4.1 不要用system("cls"),会闪瞎眼
system("cls")每次渲染清空整个屏幕,肉眼可见地闪烁,尤其方块下落时刷新频率高,画面会很难受。我推荐的做法是“帧缓冲”:先把整个画面拼成一个字符串,然后用一次输出打印出来。在控制台里,一次输出整个字符串比多次printf高效,也没有闪烁问题。
具体实现思路是:准备一个足够大的字符数组,往里面填充面板、活动方块、分数等信息,最后用printf("\033[H%s", frame)把光标移到左上角,再覆盖式输出整帧。\033[H是ANSI转义序列,Windows下如果控制台没有开启ANSI支持,可以用SetConsoleCursorPosition定位光标,效果相同。
4.2 光标定位与颜色
颜色是提升观感最快的方式。ANSI颜色代码很简单,比如\033[31m红色、\033[32m绿色,用完后\033[0m恢复默认。给不同方块上色后,可读性立刻不一样。Windows新版终端默认支持ANSI,但老版本的cmd需要先调用API开启虚拟终端模式,或者直接用SetConsoleTextAttribute设置颜色。
不过重要的不是颜色,而是把信息布局好:面板居中,右边显示分数、等级、下一块预览。如果一开始就把界面排得太乱,后面加功能会很难看。
4.3 手感细节:按键节流
控制台读取按键时,如果按键一直被按住,_kbhit会一直返回真,方块会连续移动。这其实是很多经典游戏的“自动重复”效果,本身不是坏事。但如果你发现按一下方向键方块跑好几格,可以在处理完后做一个短时间锁,例如同一按键100ms内不重复响应。这个值可以根据实际手感调。
另一个小细节:下落和输入必须放在同一个循环里处理,不要让输入阻塞逻辑更新。很多人直接把_getch放在循环里,结果方块不动、画面卡住,原因就是_getch在等待输入时把整个循环阻塞了。非阻塞的_kbhit就是为这个存在的。
5. 常见问题与排查技巧:我从调试中记下的坑
5.1 旋转后位置偏移或穿墙
如果你用旋转矩阵算坐标,最容易出现的问题是旋转中心选择不一致,导致方块旋转后整体位置漂移。我的建议是前期就用写死的四状态坐标表,彻底规避这个问题。如果一定要用公式,记牢顺时针旋转的坐标变换是(x,y) -> (y,-x),且旋转后要修正到面板边界内。
5.2 按键无反应或方向键乱码
方向键在Windows下会返回两个字节,第一个是224或0,需要再读一次才能拿到真实键值。很多人只读一次,拿到的是224,自然怎么按都没反应。调试时把读到的键值打印出来,比自己瞎猜快得多。
5.3 中文乱码
控制台默认代码页和源码编码不一致时,中文提示会变成乱码。最简单的方案是:源码里只出现英文和数字,标题栏、提示信息全用英文;或者统一用UTF-8编码加对应编译器设置。在我的项目里,为了少踩坑,界面提示我全用的英文。
5.4 游戏结束条件判断
游戏结束不是“方块碰到底了”,而是“新方块生成时就已经发生碰撞”。如果只判断固定后方块到了顶部,可能漏判。正确做法是:生成新方块时调用一次hit,马上碰撞就结束游戏。
5.5 Windows下运行一闪而过
不管是VS还是gcc,编译出的控制台程序运行完就关闭窗口,非常容易让人误以为程序没起来。调试时在main末尾加getchar()或system("pause"),或者直接在IDE里按F5运行,都能看到输出。
5.6 常见问题速查表
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 方块不动 | _getch阻塞了主循环 | 改用_kbhit非阻塞判断 |
| 方向键没反应 | 方向键双字节,只读了第一字节 | 判断到224/0后二次读取 |
| 画面闪烁 | 每次system("cls")全屏清屏 | 改成帧缓冲+光标回位 |
| 旋转位置漂移 | 旋转中心不一致 | 使用四状态写死的坐标表 |
| 消行后漏消/错位 | 行移动越界 | 从下往上遍历,移动范围限制正确 |
| 新方块被卡死 | 没检查生成时的碰撞 | spawn后立即hit,碰撞则游戏结束 |
这张表基本覆盖新手迭代过程中会碰到的大部分问题,遇到类似现象时可以先对照排查一遍。
最后分享一个我自己的习惯:写完核心逻辑后,先不要急着加“下一块预览”“最高分”“音效”这些功能,而是把主循环跑上一百次,观察每一帧的渲染和状态输出。我经常直接写一个debug函数,把board打印到文件里,每固定一次方块就输出一次,这样能清楚看到消行前和消行后的差异。你会发现,很多“看起来随机出现”的bug,其实都能从状态变化里找到规律。
这个项目做完之后,我建议你再试着做这三件事:把按方向键的手感调整到你觉得最舒服的节奏;把渲染从控制台换成SDL;把“下一块预览”加上去。每次改动都会让你再巩固一遍模块化思想,这才是“附源码”之外的真正收获。
本文还有配套的精品资源,点击获取