简介一份基于Windows API、使用VC6.0开发的俄罗斯方块完整源码工程适合初学C语言或刚刚接触Windows编程的读者参考。作者为了“自己动手写一个俄罗斯方块”查阅DOS编程、SDL、OpenGL等资料后最终选用Windows API实现工程中附有详细注释包含左右键控制方向、下键加速、上键变形、空格暂停等经典玩法。压缩包共28个文件大小约1.12MB以cpp/h/rc/ico等源码与资源文件为主附有可直接运行的exe以及dsp/dsw等VC6.0工程配置文件和obj/sbr/pch等编译过程文件便于打开工程逐步学习。该压缩包已有4514人学习下载对希望理解Windows API消息机制、绘图刷新与键盘输入处理的学习者有一定参考价值也能从中体会从想法到独立完成小游戏的完整过程。1. 一个C语言俄罗斯方块从源码里看到游戏引擎最朴素的样子俄罗斯方块大概是所有图形游戏里最适合用C语言从头写一遍的规则简单、逻辑独立、不依赖第三方库。这个项目把一个完整可玩的版本跑在了Windows窗口上方块生成、旋转、碰撞检测、消行计分、级别加速全部由纯C代码完成。对刚学完C语言想找练手项目的人来说它是把数组、结构体和Windows编程里的消息循环串起来的绝佳样本对想理解游戏底层逻辑的人来说也能从源码里看到最朴素的游戏循环与界面渲染是怎么协作的。整个工程不涉及复杂的面向对象设计有C语言基础的人都能把代码读完读完之后对程序是怎么动起来的会有完全不一样的感觉。2. 工程结构与技术选型纯C加Win32 API怎么撑起一个游戏2.1 为什么选纯C做逻辑、Win32做界面拿到这份源码第一反应可能是为什么不用更现代的框架答案很直接——这恰恰是它作为学习材料最值钱的地方。游戏逻辑本身是纯C实现的没有用到任何平台特性只有界面和输入部分使用了Windows编程里的Win32 API。这种分层意味着你可以把游戏规则部分读得明明白白再单独去看平台相关的窗口代码两条线互不干扰。Windows编程里最核心的概念是消息循环。系统把键盘事件、定时器触发、重绘请求都以消息的形式发给窗口过程函数WndProc游戏逻辑就分散在对应的消息分支里。这与控制台程序那种从上到下线性执行的方式完全不同第一次接触的人会觉得代码怎么跳着走其实这正是事件驱动模型的特征程序不是自己在跑而是被系统按消息回调。这个项目里主函数做的只有三件事注册窗口类、创建窗口、进入消息循环。剩下所有游戏行为都发生在WM_TIMER方块下落、WM_KEYDOWN按键响应、WM_PAINT画面绘制这三个消息分支里。理解了这个结构整个源码的阅读路径就清晰了顺着WinMain找到窗口过程再顺着每个消息分支往下看每一块游戏逻辑都挂在明确的触发点上。2.2 源码文件与目录结构源码的拆法遵循一个常见习惯界面与逻辑分离。拿到手的版本通常会拆成几个文件职责划分如下文件职责关键接口tetris.c程序入口、窗口注册、消息循环WinMain、WndProcgame.c方块数据、移动、旋转、消行判定initGame、spawnBlock、clearLinesdraw.c棋盘绘制、活动方块绘制、界面刷新drawBoard、drawBlockgame.h数据结构定义与全局声明Block结构体、棋盘extern变量如果你拿到的是单文件版本一个tetris.c包含全部代码结构完全一样只是三个职责写在了同一个文件的不同区段。先搜索WinMain或main定位入口再往下找WndProc窗口过程顺着消息分支就能把整个程序摸清。棋盘数据是整个游戏的状态核心用全局二维数组表示#define BOARD_WIDTH 10 #define BOARD_HEIGHT 20 int board[BOARD_HEIGHT][BOARD_WIDTH]; // 0表示空位1~7表示不同颜色的已固定方块BOARD_WIDTH和BOARD_HEIGHT用宏定义而不是写死在代码里是为了后续改棋盘尺寸方便。全局数组在工程上不算好设计但对这种单文件游戏项目来说让每个逻辑函数都能直接访问棋盘状态代码简短直白反而利于阅读和改造。2.3 方块类型与旋转状态的数据设计七种方块I、O、T、S、Z、J、L各有四个旋转方向常见做法是直接在源码里用四维数组把全部形状固化下来运行时只做下标切换不现场算旋转矩阵// shapes[type][rotation][row][col] // 值为1表示该格子被方块占据0表示空 static const int shapes[7][4][4][4] { // 0号方块I型横躺状态的4x4矩阵 { {{1,1,1,1},{0,0,0,0},{0,0,0,0},{0,0,0,0}}, {{0,0,1,0},{0,0,1,0},{0,0,1,0},{0,0,1,0}} // 还有两个旋转状态省略 } // 1~6号方块O型、T型、S型、Z型、J型、L型依次定义 };每个方块用结构体保存当前位置和旋转状态typedef struct { int x, y; // 方块左上角在棋盘上的列、行坐标 int type; // 方块类型对应shapes数组第一维下标 int rotation; // 旋转状态0~3对应shapes数组第二维下标 } Block;这种数据驱动写法好处很明显旋转只改rotation值绘制和碰撞检测都按当前type和rotation从数组取值不存在运算误差。代价是代码里会有一段看起来重复的常量数据但这是最不容易写错的设计。游戏运行时所有状态就两样东西一个棋盘二维数组一个当前活动方块的结构体。理解了这两块数据就看懂了这个项目的一半。3. 核心模块拆解定时器、碰撞检测与消行判定的完整链路3.1 游戏主循环WM_TIMER驱动方块下落Windows程序里没有while(1)那种传统游戏循环而是用SetTimer注册定时器系统每隔一段时间向窗口发送WM_TIMER消息。方块下落的逻辑就挂在这里#define FALL_INTERVAL_MS 500 // 初始下落间隔单位毫秒 case WM_TIMER: if (canMove(cur, 0, 1)) { cur.y 1; // 能下落就下移一格 } else { fixToBoard(cur); // 触底固定到棋盘 int lines clearLines();// 检查并消行 score lines * 100; // 消一行加100分 if (lines 2) { // 连续消行后加速间隔缩短 SetTimer(hwnd, GAME_TIMER, FALL_INTERVAL_MS / (level 1), NULL); } spawnBlock(cur); // 生成下一个方块 if (!canMove(cur, 0, 0)) { KillTimer(hwnd, GAME_TIMER); MessageBox(hwnd, 游戏结束, 提示, MB_OK); } } InvalidateRect(hwnd, NULL, FALSE); // 请求窗口重绘 break;这段逻辑是整个游戏的心跳。每次定时器触发方块尝试向下移动一格能移就移不能移就固定、消行、计分、换新方块。注意游戏结束的判断时机——新方块生成的位置立刻发生碰撞说明棋盘已经堆满。SetTimer第三个参数的单位是毫秒值越小方块下落越快。初始500毫秒一格每消几行就把间隔缩短常见做法是除以(level1)或者乘0.85系数这样级别越高难度曲线越陡。修改FALL_INTERVAL_MS的初始值就能直接调整游戏上手难度。3.2 移动与旋转边界判断与冲突回退碰撞检测是俄罗斯方块最容易写错的地方。常见的翻车现场是在二维数组访问之前没有先判断坐标是否越界越界读到的脏数据让方块出现卡在墙里或穿墙的诡异行为。正确的顺序是先边界后碰撞int canMove(Block *b, int dx, int dy) { for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (b-shape[b-rotation][row][col]) { // 只检查方块占用的格子 int nx b-x col dx; int ny b-y row dy; // 先检查墙壁边界越界直接拒绝 if (nx 0 || nx BOARD_WIDTH || ny BOARD_HEIGHT) { return 0; } // 再检查是否与已固定的方块重叠 if (ny 0 board[ny][nx]) { return 0; } } } } return 1; }这里有两个细节值得记下来。第一ny 0的判断不能省因为方块刚生成时部分行位于棋盘顶部之上此时访问board数组的负索引是未定义行为程序不会报错但结果不可预测。第二shape到底是直接用shapes[b-type][b-rotation]的指针还是拷贝到Block里取决于初始写法但碰撞检测的流程都一样。旋转的实现方式是先切换、再验证、冲突回退void rotateBlock(Block *b) { int oldRot b-rotation; b-rotation (b-rotation 1) % 4; // 顺时针旋转90度 if (!canMove(b, 0, 0)) { b-rotation oldRot; // 旋转后撞墙或撞方块回退 } }这种失败回退的思路在整个游戏逻辑里反复出现尝试一个动作如果条件不满足就撤销。进阶版本还会在此基础上加踢墙逻辑——旋转后发现越界尝试左右平移一到两格再判断能明显改善贴墙旋转的体验但初版可以不做。3.3 消行判定整行搬运的注意点消行的逻辑在所有版本里几乎一样从最后一行往上找满行找到就把上面的所有行往下搬一格。最容易踩坑的地方是扫描顺序和行号偏移int clearLines(int board[][BOARD_WIDTH]) { int cleared 0; for (int row BOARD_HEIGHT - 1; row 0; row--) { int full 1; for (int col 0; col BOARD_WIDTH; col) { if (board[row][col] 0) { full 0; break; } } if (full) { // 上方所有行整体下移 for (int r row; r 0; r--) { memcpy(board[r], board[r - 1], BOARD_WIDTH * sizeof(int)); } memset(board[0], 0, BOARD_WIDTH * sizeof(int)); cleared; row; // 关键当前行被替换成上面一行必须重新检查 } } return cleared; }那个row是整个函数最容易被忽略的一行。整行搬下来之后当前row位置的内容其实是原来row-1的内容如果直接continue遇到连续两个满行时只会消掉一个。memcpy做整行拷贝比逐格赋值快得多这也是C语言处理二维数组时常用的技巧——二维数组在内存里是连续分布的按行操作可以用标准库函数。消行返回值直接用于计分。经典规则是消一行100分、两行300分、三行500分、四行800分可以在二次开发时自行调整公式。有的版本在这里还会加入连击判定即短时间内连续消行额外加分需要把时间戳记录下来作为计算条件。3.4 键盘响应与GDI绘制按键操作挂在WM_KEYDOWN消息里wParam参数携带的是虚拟键码case WM_KEYDOWN: switch (wParam) { case VK_LEFT: tryMove(cur, -1, 0); break; // 左移 case VK_RIGHT: tryMove(cur, 1, 0); break; // 右移 case VK_UP: rotateBlock(cur); break; // 旋转 case VK_DOWN: tryMove(cur, 0, 1); break; // 软降 case VK_SPACE: hardDrop(cur); break; // 硬降直接落底 case P: togglePause(); break; // 暂停 } break;注意方向键对应VK_LEFT、VK_RIGHT这类虚拟键码不是字符的ASCII码。很多初学者用case a去判断方向键按键根本没反应就是这个原因。tryMove和canMove的区别在命名上就能看出tryMove内部先调用canMove通过才真正修改坐标。画面绘制用GDI完成。WM_PAINT消息里拿到设备上下文HDC按board数组的格子逐格画再叠加当前活动方块static void drawCell(HDC hdc, int col, int row, int type) { // 根据方块类型选取颜色type为0不画 if (type 0) return; COLORREF colors[] {RGB(200, 30, 30), RGB(30, 200, 30), RGB(30, 30, 200)}; HBRUSH brush CreateSolidBrush(colors[type - 1]); HBRUSH old (HBRUSH)SelectObject(hdc, brush); Rectangle(hdc, col * CELL_SIZE, row * CELL_SIZE, (col 1) * CELL_SIZE, (row 1) * CELL_SIZE); SelectObject(hdc, old); DeleteObject(brush); }CELL_SIZE是每个格子的像素边长UI布局时要把棋盘尺寸、窗口客户区尺寸算在一起。这段绘制代码如果直接跑你会看到明显的窗口闪烁原因是每次重绘都先擦背景再画格子。解决方案是双缓冲这个坑在避坑章节详细展开。4. 从源码到可执行程序编译运行与操作验证4.1 开发环境与编译器选择这个项目是纯C加Win32 API理论上任何能编译Windows程序的C编译器都行。常见的选择有两种。第一种是Visual Studio。用VS打开源码目录新建一个Windows桌面应用程序项目把源码文件加入项目直接生成。需要确认项目属性里的字符集设置与源码一致——如果源码用char数组处理字符串就把字符集设为多字节用Unicode反而会报类型不匹配的错。第二种是MinGW-W64加命令行适合习惯用编辑器写代码、命令行编译的开发者。安装MinGW-W64并配好环境变量后在源码目录直接执行编译命令。4.2 编译命令与参数说明gcc -o tetris.exe tetris.c game.c draw.c -mwindows -lgdi32 -luser32参数含义拆开解释-o指定输出文件名-mwindows表示生成GUI程序不弹控制台窗口-lgdi32和-luser32链接GDI绘图和用户界面相关的系统库。如果用的是单文件版本把中间的game.c、draw.c去掉只保留tetris.c即可。如果源码里用到了PlaySound这类多媒体接口或者多媒体定时器还需要加-lwinmm。判断方法很简单源码里出现了PlaySound函数就加没有就不加多数版本不需要。提示源码文件包含中文字符串窗口标题、游戏结束提示时编译乱码通常不是代码问题而是文件编码与编译器默认字符集不一致。确认源码保存为UTF-8或GBK后再编译。4.3 编译告警怎么看第一次编译如果用的是高版本GCC大概率会看到几个warning。最常见的是函数隐式声明原因是在调用Win32 API函数的源文件里少包含了windows.h编译器猜测函数签名的结果就是给出警告。处理方式是检查源码头部是否完整引入windows.h、stdlib.h、string.h、time.h这几个基础头文件。另一类告警是类型转换。Windows API的参数经常是UINT、LONG等类型与源码里声明的int混用时严格告警级别下会报转换警告。这通常不影响运行但想追求干净输出的可以把告警级别调低或手动把参数类型改成一致。如果用的是旧版GCC或VC可能看不到这些告警属于编译器版本差异不用过度纠结。4.4 运行验证与操作布局双击tetris.exe启动游戏。窗口标题一般显示游戏名称和当前分数。操作布局是俄罗斯方块的标准配置方向键左右移动、方向键上旋转、方向键下加速下落、空格键直接硬降、P键暂停、回车键开始新游戏。跑起来之后按这个清单验证一遍第一方块从顶部中间生成按设定间隔匀速下落第二按方向键下下落速度临时加快第三方块触底后固定顶部生成新方块第四某行填满后消失上方所有行整体下移分数增加。这四个行为分别对应canMove返回0的时机、WM_TIMER触发频率、fixToBoard和clearLines被调用的顺序。验证通过说明整个游戏逻辑的主链路是通的。5. 避坑记录闪屏、旋转越界与按键连发5.1 窗口闪屏得像跑马灯现象方块每下落一格窗口跟着闪一下快速下落时画面花成一片非常伤眼睛。原因WM_PAINT里直接绘制而InvalidateRect默认会先擦除背景。每次重绘都是擦掉整块客户区再重新画所有格子两步之间的空档让窗口露出白底视觉上就是闪烁。解决改成双缓冲绘制。先在内存里建一块画布把所有内容画到内存上然后一次性复制到窗口case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); HDC memDC CreateCompatibleDC(hdc); // 内存DC HBITMAP memBmp CreateCompatibleBitmap(hdc, WIN_WIDTH, WIN_HEIGHT); HGDIOBJ oldBmp SelectObject(memDC, memBmp); // 选入画布 drawBoard(memDC); // 在内存画布上画棋盘 drawBlock(memDC); // 画当前活动方块 BitBlt(hdc, 0, 0, WIN_WIDTH, WIN_HEIGHT, memDC, 0, 0, SRCCOPY); // 一次性上屏 SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, ps); break; }从那以后我做Windows绘图凡是需要频繁刷新的画面一律先画内存再上屏这个习惯一直保留到现在。5.2 方块旋转后卡进墙里现象方块靠在左墙边旋转时旋转后的形状有一部分超出棋盘左边界显示残缺甚至程序崩溃。原因旋转时只改了rotation值没有检查旋转后的新形状是否越界。更隐蔽的是排列组合问题——canMove里负索引没处理访问board数组负下标位置读出的是栈上或全局区里的脏数据行为完全不可预测。解决旋转必须走先验证、后生效的流程canMove内部严格按先边界、后占位的顺序判断。调试时可以在每步操作后打印方块坐标和旋转角度对比预期值很快就能定位到是哪一步的坐标越界了。5.3 按一下方向键方块连续走了好几格现象按住方向键不松手方块不是匀速移动而是猛跳几格或者延迟一下再高速连走操作手感极其诡异。原因Windows的键盘自动重复机制。按住键时系统不只会发一次WM_KEYDOWN还会按控制面板里的重复速率持续发送。游戏里每收到一次消息就移动一格叠加系统自动重复的结果就是等速连走看起来像按一下走几步。解决记录上一次按键的时间短时间内忽略重复消息case WM_KEYDOWN: if (GetTickCount() - lastKeyTime KEY_REPEAT_MS) { break; // 过滤掉系统自动重复 } lastKeyTime GetTickCount(); // 这里再处理移动、旋转 break;KEY_REPEAT_MS取100到150毫秒比较合适既能挡住系统自动重复又不影响玩家快速连敲。更彻底的做法是用GetAsyncKeyState在定时器里轮询按键状态不再依赖WM_KEYDOWN但改动量较大时间戳过滤是最小干预方案。5.4 消行函数陷入死循环现象程序运行到连续消行时卡死窗口失去响应任务管理器看CPU占用接近满载。原因上文中提到的row重新检查逻辑在边界情况下会出问题。从下往上扫描时如果行首执行row又把当前行替换成了满行扫描索引会在数组边界反复横跳最后陷入死循环。解决改成重建棋盘的方式把不满行的行从下往上复制到新数组顶部剩余位置置零。逻辑更直白不会出现索引绕晕的问题int clearLines(int board[][BOARD_WIDTH]) { int next[BOARD_HEIGHT][BOARD_WIDTH] {0}; int target BOARD_HEIGHT - 1; int cleared 0; for (int row BOARD_HEIGHT - 1; row 0; row--) { int full 1; for (int col 0; col BOARD_WIDTH; col) { if (board[row][col] 0) { full 0; break; } } if (full) cleared; // 满行丢弃 else memcpy(next[target--], board[row], BOARD_WIDTH * sizeof(int)); } memcpy(board, next, sizeof(next)); // 复制回原棋盘 return cleared; }排查那次死循环花了不少时间最后是靠逐步打印消行前后棋盘内容才定位到索引问题。从那以后我写消行一律优先用重建棋盘思路不再用原地搬移加索引补偿的花活。6. 二次开发改出属于你自己的俄罗斯方块版本源码跑通只是第一步真正把这千行代码吃透的标志是改出自己的版本。分享三个改动性价比最高的方向。第一个是下一个方块预览。在棋盘右侧开辟一块4x4小区域每次spawnBlock时把再下一个方块存入preview变量绘制时同步画出。实现上只需加一个Block preview字段在生成逻辑里提前赋值再在draw函数里多画一次半小时内能改完。这个功能对可玩性提升非常明显玩家能提前规划摆放位置。第二个是完善计分与等级体系。把clearLines的返回值用起来实现经典积分规则消一行100分、两行300分、三行500分、四行800分。同时记录累计消行数每消满10行升一级升级时把定时器间隔缩短15%。这套逻辑不需要任何新知识纯粹是把已有的返回值组织成状态机。第三个是操作手感优化空间最大。加上前面提过的踢墙逻辑旋转失败时尝试左右平移一到两格再判断贴墙旋转的体验会显著改善。还可以把硬降改成软降——按住方向键下才加速下落松手恢复原速这需要把WM_KEYDOWN和WM_KEYUP结合使用比现在的单键触发复杂一点但手感更细腻。改完之后建议跑一组固定的验证用例把方块分别卡在左边界、右边界、中间位置旋转确认没有越界和崩溃连续构造四行满行验证计分公式玩到最高等级确认定时器没有缩到零导致死循环。这几个用例跑过你的版本基本就稳了。俄罗斯方块这种项目改得越多越能体会游戏逻辑全是数据运算这句话——碰撞检测是数组下标比较旋转是状态切换消行是行搬运。这是拆这个项目我最大的感受它不复杂但每一行都值得一行行读过去。希望帮到你。本文还有配套的精品资源点击获取