简介如果你是C语言学习者想尝试自己动手写一个小游戏这份俄罗斯方块源码值得参考。它基于C语言与Windows API实现作者在VC6.0环境下完成关键代码均有注释能帮助理解窗口创建、消息循环、键盘响应和DC绘图等Windows编程基础。压缩包共28个文件主要包含cpp源文件、h头文件、ico图标资源以及编译生成的obj、exe等整体约1.12MB工程结构完整既可用VC6打开源码调试也可直接运行exe体验游戏效果。游戏支持左右方向控制、下键加速、上键变形、空格暂停从方块生成、碰撞判断、旋转到消行计分等环节都有清晰实现适合逐段拆解或在此基础上做扩展。目前已有4514人学习下载对希望从控制台转向图形界面编程的C语言初学者来说是一份完整且可直接实践的小型项目。1. C语言俄罗斯方块为什么经典小游戏是最好的练手项目C语言写的俄罗斯方块放在今天依然是个不过时的练手题。它没有复杂的业务逻辑却恰好把C语言里最容易让人翻车的几件事全部压进同一个程序二维数组越界、结构体拷贝、循环边界、输入缓冲。写着写着你就会意识到这个号称简单的小游戏其实是给C语言初学者设的一场综合测验而且反馈极其即时——哪里写错了画面立刻告诉你。它适合已经学完指针与结构体、想用一个完整项目巩固C语言基础的同学也适合准备给简历加一个控制台游戏项目的从业者。本文以控制台版为基线把架构、数据结构、核心算法、常见坑和进阶手感逐层讲透所有代码都是可直接抄走的最小实现。2. 游戏架构先于代码状态机与渲染方式怎么选很多人上手写俄罗斯方块第一个动作就是画界面、写键盘检测结果写到一半发现逻辑和界面全缠在一起改一个地方崩三个地方。俄罗斯方块再小也是一个有状态的实时游戏先把结构定清楚后面所有代码才有地方安放。2.1 游戏状态机拆解把俄罗斯方块拆成五个独立环节俄罗斯方块的单局流程可以拆成五个老实的环节生成新方块、处理玩家输入、尝试移动/旋转/硬降、判断是否落定、消行与计分。这五个环节不是顺序执行完一圈就结束而是围绕当前方块是否落定这个条件来回切换。// 核心状态枚举只用来表达游戏正处于什么阶段 typedef enum { ST_SPAWN, // 生成新方块 ST_ACTIVE, // 方块下落中可响应输入 ST_LOCK, // 方块落定准备固化到格子表 ST_CLEAR, // 执行消行与计分 ST_OVER // 游戏结束 } GameState; // 把这些状态做成一个循环主循环只做一个事调状态 GameState state ST_SPAWN; while (state ! ST_OVER) { switch (state) { case ST_SPAWN: state spawnBlock(cur, next); break; case ST_ACTIVE: state processInput(cur, board, pressedKey); break; case ST_LOCK: state lockBlock(board, cur); break; case ST_CLEAR: state clearLines(board, score); break; default: break; } render(board, cur, next, score); // 每一轮都重绘 }这段代码的意义在于把什么时候该干什么彻底从怎么画里面剥离开。spawnBlock只负责在出生点放一个方块processInput只处理键盘事件lockBlock把当前方块写进场地数组clearLines检查满行并消行。每个函数都只操作一个数据结构错误定位时你只需要看一个函数。注意这里的循环不是按帧率走的render每轮都调用但真正决定游戏节奏的是ST_ACTIVE状态里的下落计时。建议把ST_ACTIVE的处理拆成两段一段处理按键输入一段检查下落时间是否已到两段独立判断。如果你把按键处理和下落放在同一个if里会出现按住按键时方块加速下落的怪毛病。2.2 渲染方式怎么选终端字符与图形库两种方案渲染层有两种主流选择选哪个取决于你的目标和运行环境。用控制台字符画渲染零依赖、跨平台最省事但视觉上只能说能分清形状用图形库画方块观感好很多但需要额外装库、处理窗口事件。方案依赖运行环境适合场景成本终端字符渲染无任何终端练手、笔试演示、快速实验极低图形库渲染需引入图形库头文件对应图形库支持的窗口环境交作业、作品展示、游戏手感调试中等我一般建议第一版用终端字符渲染把精力全部放在逻辑上等逻辑稳定后再考虑换图形库。这里给出一个最小终端网格绘制函数用printf输出场地。void render(int board[H][W], Block *cur, Block *next) { system(cls); // Linux 下改成 system(clear) for (int y 0; y H; y) { for (int x 0; x W; x) { int printed 0; // 先看当前方块是否占了这格 for (int i 0; i 4; i) { int bx cur-x cur-shape[i][0]; int by cur-y cur-shape[i][1]; if (bx x by y) { printf(# ); printed 1; break; } } if (!printed) { printf(board[y][x] ? # : . ); } } printf(\n); } }这段代码用#表示有方块、.表示空格场地的静态格子和当前活动方块都在同一张网格上输出。system(cls)每次清屏会造成轻微闪烁但在练手阶段完全可接受。注意这里每个格子输出两个字符井号加空格目的就是让宽度方向看起来更接近正方形否则终端字符通常是高比宽大方块会显得瘦长。渲染实现的另一个关键点是场地数组board只存放已经锁定的方块当前正在下落的方块不写入board而是单独用一个Block结构体维护。这样你不需要在下落过程中反复改board碰撞检测和落定固化都干净很多。我在早期版本里图省事把当前方块也写进数组结果每帧都要擦掉重画还容易残留垃圾数据纯属自找麻烦。3. 方块数据结构从7种形状到旋转矩阵的表示俄罗斯方块的全部游戏性都建立在7种方块的表示与旋转上这个选择直接决定碰撞检测和旋转代码写起来是顺滑还是到处打补丁。数据表示没有唯一正确答案但有明显优劣之分。3.1 用坐标数组表达7种方块定义与初始化经典思路有二维矩阵和坐标数组两种。二维矩阵用4x4数组有格子填1直观但旋转时要搬动整个矩阵且空白区域多判断碰撞时还要排除矩阵里的空位坐标数组把每个方块抽象成4个格子的(x, y)相对坐标旋转就是直接修改坐标碰撞检测代码也只需遍历这4个点。推荐后者。#define W 10 // 场地宽 #define H 20 // 场地高 // 方块类型有7种用0~6表示每种方块有4个旋转态 // shape[type][rot][i][0]x, shape[type][rot][i][1]yi为0~3 int SHAPES[7][4][4][2] { // 0: I { {{0,0},{1,0},{2,0},{3,0}}, {{1,-1},{1,0},{1,1},{1,2}}, {{0,0},{1,0},{2,0},{3,0}}, {{1,-1},{1,0},{1,1},{1,2}} }, // 1: O { {{0,0},{1,0},{0,1},{1,1}} // O方块所有旋转态相同只需写一个其余三个补同样内容即可 }, // 2: T { {{0,0},{1,0},{2,0},{1,1}}, {{1,0},{0,1},{1,1},{1,2}}, {{0,0},{1,0},{2,0},{1,-1}}, {{1,-2},{1,-1},{1,0},{2,-1}} } // S、Z、L、J 照此办理 };这段定义的要点是锚点选择。我用的是方块自身的某个中心格作为(0,0)所有坐标都是相对锚点的偏移。旋转时锚点不动围绕锚点转90度这样方块旋转时位置变化最小后续碰撞检测也最容易理解。SHAPES是三维数组这件事很容易让人绕晕。读的时候一层一层拆先取type得到4个旋转态再取rot得到当前旋转态下4个格子的偏移最后取i和坐标分量得到具体值。实际使用时建议包一层宏或函数避免在业务代码里直接写三层下标。由于预定义旋转表把每个旋转态的坐标都写死了代码会比较长但换来的是运行期零计算、不易出错。作为练手项目这完全可以接受。如果你追求更紧凑的代码风格可以用一个旋转函数根据当前角度算出坐标但那个方案的坐标计算和碰撞判定交错在一起逻辑不如查表清晰。3.2 方块旋转查表法与转置法我为什么选查表旋转的实现方式决定了后续代码会不会在奇怪的地方崩掉。查表法最稳但代码量大转置法节省代码量但要在运行时计算坐标并且会把旋转中心的一致性搞乱。转置法的本质是把每个格子的坐标做一次线性变换公式如下int new_x -old_y; int new_y old_x;这个公式在屏幕坐标系y轴向下里实现的是顺时针旋转90度。如果搞反方向方块每次按逆时针转玩起来会觉得完全不对劲。很多人写完旋转测试半天发现方向是反的就是因为用数学课上y轴朝上的习惯套进了屏幕坐标系。我最终选择查表法作为主力方案。不止是因为它稳更因为它把某个旋转态下格子在哪变成纯粹的静态数据check_collision在判断碰撞时只需读表不用在脑中推算坐标变化。这对于代码可读性和调试体验的提升非常明显。唯一的代价是表写起来繁琐我的做法是把7种方块的4个旋转态在草稿纸上画一遍再填进数组画一轮能省后面两个小时的查错时间。旋转操作本身只需要改计数器void rotateBlock(Block *b, int direction) { int newRot (b-rot direction 4) % 4; // direction1顺时针, -1逆时针 // 先做合法性检查检查通过才更新旋转态 Block tmp *b; tmp.rot newRot; if (!checkCollision(board, tmp)) { b-rot newRot; } }关键点在于先检查再更新。tmp是一个副本用它去执行checkCollision合法才写回原方块不合法则什么都不发生。把这个顺序写反的人会在旋转时出现方块嵌进墙壁或者消失的灵异现场原因就是你改了旋转态之后做检查失败了想恢复原状又忘了把坐标也恢复。direction参数值为1或-1取模是为了防止旋转态下标溢出。T旋、S旋这些高级玩法并不需要额外的代码分支它们本质上是旋转被墙壁挡住后尝试平移再旋转的组合等我后面讲进阶技巧时再展开。4. 核心逻辑落地碰撞检测、消行判定与下落控制架构定了、数据结构定了俄罗斯方块真正的主战场就是碰撞检测、消行和下落的节奏控制。这三个逻辑占了整个程序最容易被问为什么这样写的部分也是性能与手感的分水岭。4.1 碰撞检测边界、底与已堆叠格子一个都不能漏碰撞检测是所有俄罗斯方块实现的心脏。它需要同时检查三件事是否超出左右边界、是否超出底部、是否与已锁定的方块重叠。边界条件是固定的但已堆叠格子的检查必须依赖board数组的值。int checkCollision(int board[H][W], Block *b) { for (int i 0; i 4; i) { int x b-x b-shape[i][0]; int y b-y b-shape[i][1]; // 超出左右边界或底部碰到就算碰撞 if (x 0 || x W || y H) return 1; // 超出顶部先不判死给方块留出生成空间 if (y 0) continue; // 与已落定的方块重叠算碰撞 if (board[y][x] 1) return 1; } return 0; }注意y 0的处理绝大多数实现会在这里直接把y 0也算作碰撞但这会导致新方块出生位置一旦有方块残留就立刻游戏结束或者在顶部堆积时方块完全没有调整余量。正确做法是允许方块部分悬在空中只有真正撞到侧面、底部或已有方块才算碰撞。这个细节就是很多人说的方块出生即死问题的根源。调用方只需要做一个简单判断Block tmp cur; tmp.y 1; // 尝试往下移动一格 if (!checkCollision(board, tmp)) { cur.y 1; // 移动成功 } else { lockBlock(board, cur); // 移动失败落定 }这里用了tmp副本而不是直接cur.y 1再判断原因与旋转一致检查失败时能保证原始状态不被破坏。若直接修改再判断失败后还要写恢复代码容易漏。lockBlock会把当前方块的四个格子写入board并把方块状态切回生成态让下一个方块登场。碰撞检测还有一个隐含要求checkCollision必须能够被高频调用而不产生卡顿。一次碰撞检查最多循环4个格子每次操作都是常量级所以这个函数可以放心用在移动、旋转、硬降和阴影预览四种场景里不需要担心性能。4.2 消行判定从底部向上扫描并搬移整行消行逻辑在首次实现时会觉得很简单但它最容易被写成一个看着对、跑起来错的黑匣子。常见错误有两个遍历方向反了、搬移行时覆盖了未处理的数据。int clearLines(int board[H][W], int *score) { int lines 0; for (int y H - 1; y 0; y--) { int full 1; for (int x 0; x W; x) { if (board[y][x] 0) { full 0; break; } } if (full) { // 把当前行以上的所有行整体向下搬一格 for (int yy y; yy 0; yy--) { for (int x 0; x W; x) { board[yy][x] board[yy - 1][x]; } } // 最顶部一行清空 for (int x 0; x W; x) { board[0][x] 0; } lines; y; // 关键当前行被替换成了上面的行需要重新检查 } } *score lines * 100 * lines; // 一次消多行给更高奖励 return lines; }先看遍历方向从底部往上扫是因为消掉一行后所有上方的行都会下移一格。如果从顶部往下扫搬移操作会把刚刚检查过的行又带回来导致漏判。y这一行是后悔药源行被搬下来了这一行里可能仍有新搬来的满行所以强制让循环重新检查当前行的位置。代码里直接用了两层循环做搬移没有用memcpy因为board的每一行就是一个长度为W的数组用memcpy(board[y], board[y-1], sizeof(board[0]))效果一样但可读性略差一点。对于练手代码清晰比简洁更值得优先。计分规则我按消1行100分一次消4行直接1600分的曲线设计。lines * 100 * lines这种写法对1行来说是1002行是4003行是9004行是1600与经典俄罗斯方块的计分逻辑吻合。如果你觉得分数涨太快可以改成lines * 100这完全看个人喜好不需要跟任何人保持一致。4.3 下落节奏用时间差控制而不是Sleep裸循环新手最容易犯的一个错误是下面这种写法方块下落到一半时程序进入Sleep(1000)整个界面卡死一秒钟按键输入也全部失效。当游戏速度加快后这个问题会恶劣到完全没法玩。// 错误示范Sleep会阻塞主循环按键全失灵 if (isTimeToFall) { cur.y 1; Sleep(fallInterval); }正确做法是用系统时钟计算时间差每帧检查是否到了下落时刻unsigned int lastTick clock(); // clock() 返回毫秒级时间需包含 time.h int fallInterval 800; // 毫秒初始即每0.8秒下落一格 while (state ! ST_OVER) { // 这一段处理键盘输入不管有没有输入都执行 handleInput(cur, board, fallInterval, score); // 这一段判断是否该下落了 unsigned int now clock(); if (now - lastTick (unsigned int)fallInterval) { Block tmp cur; tmp.y 1; if (!checkCollision(board, tmp)) { cur.y 1; } else { lockBlock(board, cur); state ST_SPAWN; } lastTick now; } render(board, cur, next, score); }clock()返回的是程序自启动以来的CPU时间单位为毫秒Windows与多数编译环境下。用now - lastTick fallInterval判断是否到点每次下落成功后再更新lastTick这样下落速度和游戏帧率就解耦了。哪怕画面刷新率不稳定方块的下落速度也是均匀的这是手感稳定的关键。handleInput里要做的事是读取按键并立即做出移动或旋转操作。注意它不是阻塞函数用非阻塞方式读键盘读不到按键就立即返回这样主循环才能保证每帧都刷新得够快。下落间隔fallInterval与游戏等级的匹配是另一个常见坑。直接把等级做成倍数关系比如等级2就是原始间隔除以2会让后期速度快到无法反应。经典做法是线性加速每消除10行等级1下落间隔减少一个固定值比如50毫秒但设置下限低于200毫秒就封顶。没有这个封顶方块快到基本等于直接游戏结束。5. 踩坑与排查最容易翻车的4个细节这部分写的是我在给几个学弟学妹调试俄罗斯方块代码时反复遇到的典型问题。它们不是理论上的可能出错而是几乎每份初学者代码都会出现的共性问题。5.1 方块一出生就死出生点碰撞检测的时序问题现象新方块生成后还没等玩家按键就立刻提示游戏结束或者方块卡在场地顶部完全移动不了。这通常发生在场地堆到一定高度后。原因生成位置与碰撞检测的判定逻辑冲突。如果生成函数直接把方块的 y 设置在场地顶部y0而场地顶部已经有锁定的方块残留那么出生时的碰撞检测结果必然是碰撞于是游戏直接结束。另一个常见原因是碰撞检测函数把y 0也算作碰撞导致方块出生位置卡在天花板上面直接被判死。解决把出生位置放在场地顶部上方y 设为 -2同时把checkCollision里y 0的情况排除在碰撞之外。这样新方块会有一段从天上落进来的过渡空间即使顶部堆满了也不会一出生就触发游戏结束。游戏真正结束的判定应该放在lockBlock之后检查新方块是否能够在出生点合法放置如果不合法才结束。5.2 旋转穿墙与数组越界先检查再写入现象方块旋转时会直接嵌进墙壁消失或者程序崩溃报错位置在checkCollision读取board[y][x]时数组越界。原因旋转逻辑里直接修改了方块的旋转态然后才做碰撞检查检查不通过时没有恢复原状。更危险的一种是旋转后的坐标落在负值区域board[-1][x]这种非法访问直接带崩程序。解决严格执行先复制、再修改、再检查、通过才写入的四步流程。旋转和移动都创建一个Block tmp cur在tmp上尝试操作checkCollision通过后再赋值给cur。数组越界的防御点有两个碰撞检测里强制检查x 0 || x W || y H先返回碰撞SHAPES表的三角函数值也尽量控制在合法范围内。这样即使旋转出界也只是返回不能旋转而不是直接让进程崩溃。5.3 消行后上方方块不落下来搬移方向写反了现象看起来已经消除了一行但上面几行方块没有往下移动或者移动后出现了大面积的错位和覆盖。原因消行后搬移循环的遍历方向错了。如果从顶部向底部遍历执行搬移那么上面行搬下来之后源行已经被下方行的数据覆盖等到循环处理到下面的行时数据已经不是原来的了。更隐蔽的问题是消行后用continue跳过重新检查导致连续满行的情况只消了一行就停止。解决严格从H - 1向上遍历到0消行后执行从当前行往上每一行替换成上一行的搬移逻辑并在搬移后给循环变量做一次回退y配合外层y--实现重新检查。这个细节必须配合一个构造好的满行场景来测试而不是只消一行就完事。测试用例建议用一行中间夹一个空格的场景消掉中间行确认上下数据正确衔接。5.4 按键乱跳与卡键输入缓冲区清空与重复延迟现象快速按键时方块连续移动多次或者按一次左键方块直接飞到最左边按下落键后方块瞬间落到底部无法实现点一下就下落一格的精确控制。原因终端输入函数如kbhit、getch会产生按键重复行为。当玩家按住方向键时系统自带的按键重复机制会让getch返回多个相同的按键值你的程序就会把一次按键当作多次处理。中文输入法开启时某些按键会被拦截或重复导致输入完全失灵。解决在输入处理中只对按下瞬间做响应不对按住状态做连续响应。做法是记录上一次按键的扫描码如果本次读取到的按键值相同且间隔小于某个阈值比如200毫秒就忽略本次按键。这个简单的重复过滤就能让按键手感从发疯变成可控。// 输入处理中的重复按键过滤片段 int lastKey 0; unsigned int lastKeyTime 0; void handleInput(Block *cur, int *lastKey, unsigned int *lastKeyTime) { while (_kbhit()) { // 非阻塞读按键 int key _getch(); if (key ! *lastKey || clock() - *lastKeyTime 200) { // 处理按键移动、旋转、硬降 *lastKey key; *lastKeyTime clock(); } } }_kbhit和_getch是 Windows 环境下 conio.h 提供的函数Linux 下需要用select加read或者ncurses来实现等效逻辑。while (_kbhit())的意图是一次性清空缓冲区里堆积的所有按键避免按下一次方向键后缓冲区里存的重复值在后续帧里被慢慢消费掉造成按一次动两格的飘移感。这4个坑几乎覆盖了初次实现俄罗斯方块时90%的翻车现场。只要把碰撞检测的时序、旋转的先检查后写入、消行的遍历方向、输入缓冲的清理这四件事做对这个项目的主干就算真正稳了。6. 进阶从能跑到能玩给游戏加上硬降与阴影预览当你的俄罗斯方块能正常进行到十几行以上时你会发现一个能跑和能玩之间有一道明显的分水岭手感。三个小而关键的改进能让这个游戏从教学Demo变成让人想继续玩下去的作品。第一个是硬降功能。按下空格时当前方块快速落到最低处并落定经典规则是硬降每穿过一个格子给2分。实现不复杂提前计算落点然后直接把cur.y改过去int dropBlock(int board[H][W], Block *b, int *score) { Block tmp *b; while (1) { Block next tmp; next.y 1; if (checkCollision(board, next)) break; tmp next; (*score) 2; // 硬降加分 } *b tmp; lockBlock(board, b); return 1; }第二个是阴影预览。这个功能对策略性影响极大玩家能看到当前方块会落到什么位置从而提前规划摆放。阴影不是真正的方块而是把当前方块的形状复制一份不断往下移动直到碰撞再用不同的字符或颜色绘制Block calcShadow(int board[H][W], Block *b) { Block shadow *b; while (1) { Block next shadow; next.y 1; if (checkCollision(board, next)) break; shadow next; } return shadow; }阴影和硬降的核心都是向下尝试移动直到撞到东西所以checkCollision被调用了很多次。好在这个函数本身就是 O(1) 级别的这两项功能加进去之后看不出任何性能开销。第三个是旋转踢墙kick处理。前面旋转函数里方块旋转碰到墙时直接返回不能旋转但经典俄罗斯方块允许旋转时向左右平移来蹭进缝隙。简单实现是旋转失败时尝试向右平移一格再旋转、向左平移一格再旋转、平移两格再旋转任何一个成功就接受。这三行逻辑就能让方块的旋转手感变得灵活很多不必实现完整复杂的 SRS 踢墙表。我现在的习惯是每写完一个功能就先跑10局不同关卡把这个操作在别的游戏里该怎么反应当作验收标准而不是只检查代码有没有崩溃。俄罗斯方块这种老游戏游戏性文档随处可查手感标准也在每个玩家的肌肉记忆里把这几条进阶操作加进去你的作品才具备让人愿意玩下去的说服力。这个项目做完之后我最大的收获不是 C 语言又多熟而是清晰的架构和严谨的边界检查能让你在游戏手感上调出远超预期的空间希望帮到你。本文还有配套的精品资源点击获取