
简介这是一份面向C语言初学者与Windows编程入门者的实战项目源码用Windows API在VC6.0环境下实现经典俄罗斯方块游戏适合想通过完整小项目理解窗口消息、绘图与键盘响应机制的学习者练手。压缩包共28个文件、约1.12MB包含cpp与h源码、ico图标、rc资源脚本、dsp与dsw工程文件及exe可执行程序等源码均带注释便于对照阅读游戏主循环、方块变形与消行逻辑。操作方式为左右键控制方向、下键加速、上键变形、空格暂停可直接运行体验后再逐行研读实现细节。目前已有4515人学习下载作者为刚毕业的开发者坦言借该项目边学Windows编程边完成多年心愿对希望从控制台过渡到图形界面编程的读者具有较好的参考与借鉴价值。1. 从一块会“转”的方块说起这个 C 语言俄罗斯方块到底能干什么很多人第一次接触 Windows 图形编程不是从窗口消息循环开始的而是从一块往下掉的方块开始的。这份用 C 语言写的俄罗斯方块核心价值不在于“又一个方块游戏”而在于它把 Win32 窗口、GDI 绘图、键盘消息、定时器、二维数组状态机这几样东西压缩进了一个能编译、能跑、能改的完整工程里。你拿到源码后改一个常量就能调速度改一个数组就能换方块形状改一段绘制逻辑就能换配色这种“看得见反馈”的练手体验是看十篇 API 文档换不来的。它适合两类人一类是刚学完 C 语言语法、想找个能跑起来的 Windows 桌面项目练手的新手另一类是熟手想找一个结构干净、没有第三方依赖、可以直接拿来做课程设计或二次开发底座的参考实现。整个项目不依赖任何图形库纯 Win32 API 加 GDI编译出来就是一个独立 exe扔到哪台 Windows 机器上都能双击运行这一点在“Windows 编程 C 语言 俄罗斯方块”这个组合里其实挺难得。2. 工程结构与技术选型一个 Win32 程序的最小闭环2.1 目录与文件划分这类项目通常不会搞得很复杂常见做法是把代码拆成三到五个文件职责清晰但不至于过度设计。下面是我拆这类源码时最常看到的组织方式也是我建议你在二次开发时保持的结构tetris/ ├── main.c // WinMain 入口、窗口注册、消息循环 ├── game.h // 常量、结构体、全局状态声明 ├── game.c // 游戏逻辑方块生成、碰撞、消行、计分 ├── render.c // GDI 绘制背景、方块、网格、分数 └── resource.h // 资源 ID图标、菜单等可选main.c只负责“把窗口立起来”game.c只负责“规则怎么算”render.c只负责“画成什么样”。这种切法的好处是你改游戏规则时不用碰绘图代码改配色时不用碰逻辑代码。新手最容易犯的错是把所有东西塞进WndProc一个函数里几百行下去消息处理和游戏逻辑搅在一起后面想加个“下一个方块预览”都无从下手。2.2 核心数据结构俄罗斯方块的本质是一个二维网格加一组方块定义。常见做法是用一个int二维数组表示棋盘0 表示空非 0 表示已固定的方块颜色索引当前下落的方块单独用结构体维护记录形状、旋转状态、行列坐标。// game.h #define BOARD_COLS 10 #define BOARD_ROWS 20 #define CELL_SIZE 24 // 7 种方块每种 4 个旋转态每个旋转态用 4x4 掩码表示 typedef struct { int cells[4][4]; // 1 表示该格有方块 } Shape; typedef struct { int shapeIndex; // 当前是哪种方块 0~6 int rotation; // 当前旋转态 0~3 int row, col; // 左上角在棋盘中的位置 } Piece; extern int board[BOARD_ROWS][BOARD_COLS]; extern Piece current; extern int score; extern int gameOver;board用全局数组是为了让game.c和render.c都能直接访问省去传参的麻烦。这在小型项目里是可接受的取舍但如果你要把它改成多局对战或者加撤销功能就得把状态收进一个GameState结构体里统一管理。CELL_SIZE是像素单位改它就能整体缩放棋盘窗口尺寸要同步跟着算这是后面改造时的一个关键联动点。2.3 窗口创建与消息循环Win32 程序的骨架是固定的注册窗口类、创建窗口、进入消息循环。真正需要你关注的是WndProc里处理了哪些消息。这个项目里最关键的三个消息是WM_CREATE初始化游戏状态和定时器、WM_TIMER驱动方块下落、WM_KEYDOWN处理左右移动、旋转、加速下落。// main.c 片段 LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: InitGame(); // 初始化棋盘、分数、当前方块 SetTimer(hwnd, TIMER_ID, 500, NULL); // 500ms 下落一格 return 0; case WM_TIMER: if (wParam TIMER_ID) { GameTick(); // 尝试下移失败则固定并生成新方块 InvalidateRect(hwnd, NULL, FALSE); // 请求重绘 } return 0; case WM_KEYDOWN: HandleKey(wParam); // 左右/旋转/硬降 InvalidateRect(hwnd, NULL, FALSE); return 0; case WM_PAINT: Render(hwnd); // 全部绘制在 render.c return 0; case WM_DESTROY: KillTimer(hwnd, TIMER_ID); PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); }SetTimer的第三个参数是下落间隔单位毫秒。500 是偏慢的入门速度想加难度就在消行后动态调小这个值比如每消 10 行减 30ms最低不低于 100ms。InvalidateRect只是标记区域无效真正绘制发生在随后的WM_PAINT里这是 Win32 的绘制模型别在WM_TIMER里直接画否则窗口被遮挡再恢复时画面就花了。2.4 GDI 绘制把数组翻译成像素绘制部分的核心是把board和current两个状态映射到屏幕坐标。常见做法是先画背景网格再画已固定的方块最后画当前下落方块顺序不能反否则当前方块会被盖住。// render.c 片段 static void DrawCell(HDC hdc, int row, int col, COLORREF color) { int x col * CELL_SIZE; int y row * CELL_SIZE; HBRUSH brush CreateSolidBrush(color); RECT r { x, y, x CELL_SIZE, y CELL_SIZE }; FillRect(hdc, r, brush); DeleteObject(brush); // 描边让方块之间有分隔感 FrameRect(hdc, r, (HBRUSH)GetStockObject(BLACK_BRUSH)); } void Render(HWND hwnd) { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 双缓冲避免闪烁 HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, BOARD_COLS * CELL_SIZE, BOARD_ROWS * CELL_SIZE); SelectObject(memDC, memBmp); // ... 在 memDC 上绘制背景、固定方块、当前方块 ... BitBlt(hdc, 0, 0, BOARD_COLS * CELL_SIZE, BOARD_ROWS * CELL_SIZE, memDC, 0, 0, SRCCOPY); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, ps); }CreateSolidBrush每次绘制都创建再删除在 500ms 一帧的频率下没问题但如果你把定时器调到 16ms 做流畅动画就得把画刷缓存起来复用否则 GDI 对象频繁创建销毁会拖慢甚至泄漏。双缓冲那段是消除闪烁的关键CreateCompatibleBitmap创建的位图尺寸必须和客户区一致窗口大小改了这里也要跟着改这是新手最容易翻车的地方之一。3. 游戏逻辑拆解碰撞、旋转与消行的判定链路3.1 方块定义与旋转7 种方块I、O、T、S、Z、J、L每种有 4 个旋转态。最省事的写法是把每个旋转态硬编码成 4x4 掩码旋转时直接切换rotation索引而不是在运行时做矩阵变换。硬编码的好处是直观、不易出错代价是代码长一点。// game.c 片段以 T 型为例 static const Shape shapes[7] { // ... 其他方块省略 ... { { {0,1,0,0}, {1,1,1,0}, {0,0,0,0}, {0,0,0,0} } }, // T 旋转态 0 { { {0,1,0,0}, {0,1,1,0}, {0,1,0,0}, {0,0,0,0} } }, // T 旋转态 1 { { {0,0,0,0}, {1,1,1,0}, {0,1,0,0}, {0,0,0,0} } }, // T 旋转态 2 { { {0,1,0,0}, {1,1,0,0}, {0,1,0,0}, {0,0,0,0} } }, // T 旋转态 3 };旋转操作就是rotation (rotation 1) % 4然后做一次碰撞检测如果新状态越界或重叠就回退。这里有个细节旋转后如果贴墙常见做法是尝试左右平移一格再检测俗称 wall kick不做这个的话贴墙旋转会直接失败手感很别扭。新手版本通常不做 wall kick你可以把它作为第一个改造点。3.2 碰撞检测碰撞检测是游戏逻辑的心脏判断当前方块在目标位置是否合法。逻辑很直接遍历方块的 4x4 掩码对每个为 1 的格子算出棋盘坐标检查是否越界或已被占用。// game.c int IsValidPosition(int shapeIndex, int rotation, int row, int col) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (!shapes[shapeIndex].cells[rotation][r][c]) continue; int br row r; int bc col c; if (bc 0 || bc BOARD_COLS) return 0; // 左右越界 if (br BOARD_ROWS) return 0; // 触底 if (br 0 board[br][bc]) return 0; // 与已固定方块重叠 } } return 1; }注意br 0这个判断方块刚生成时可能有一部分在棋盘上方负行这是合法的只要不越下边界就行。很多实现漏掉这个条件导致方块一出生就被判定碰撞游戏直接卡死。这个坑我在不止一份学生作业里见过。3.3 消行与计分消行的逻辑是从底往上扫某一行全非 0 就删掉上面的行整体下移。计分常见做法是消 1 行 100 分、2 行 300 分、3 行 500 分、4 行 800 分倍数递增给玩家连消的动力。// game.c int ClearLines(void) { int cleared 0; for (int r BOARD_ROWS - 1; r 0; r--) { int full 1; for (int c 0; c BOARD_COLS; c) { if (!board[r][c]) { full 0; break; } } if (full) { // 把 r 以上的行整体下移一行 for (int rr r; rr 0; rr--) { memcpy(board[rr], board[rr - 1], sizeof(board[0])); } memset(board[0], 0, sizeof(board[0])); cleared; r; // 重新检查当前行因为上面的行已经移下来了 } } return cleared; }r那行是精髓删掉一行后原来在r-1的行移到了r必须重新检查这一行否则连续满行会漏消。这个细节不写注释很容易被后来改代码的人删掉然后出现“消了一行还剩一行没消”的玄学 bug。3.4 主循环与状态推进GameTick是每次定时器触发时调用的推进函数负责“尝试下移不行就固定固定后消行、计分、生成新方块、判断游戏结束”。// game.c void GameTick(void) { if (gameOver) return; if (IsValidPosition(current.shapeIndex, current.rotation, current.row 1, current.col)) { current.row; // 正常下落 } else { LockPiece(); // 固定到 board int lines ClearLines(); if (lines 0) { score (lines 1) ? 100 : (lines 2) ? 300 : (lines 3) ? 500 : 800; } SpawnPiece(); // 生成新方块 if (!IsValidPosition(current.shapeIndex, current.rotation, current.row, current.col)) { gameOver 1; // 新方块一出生就碰撞游戏结束 } } }LockPiece就是把当前方块的掩码写进board颜色索引取shapeIndex 1。SpawnPiece把新方块放在顶部中间row初始为负值让方块从上方“掉进来”。游戏结束的判定放在生成之后而不是固定之后这样玩家能看到最后一块落定的画面体验更完整。4. 编译运行与调试从源码到可执行文件4.1 编译环境选择这个项目是纯 Win32不依赖任何第三方库所以编译方式很灵活。常见做法有三种Visual Studio 建空项目把.c文件加进去MinGW-w64 命令行直接gcc或者用 CMake 包一层。我一般推荐新手用 Visual Studio因为调试器好用断点、监视窗口、调用栈都是现成的。用 MinGW 的话命令行大概是这样# 在源码目录下假设文件是 main.c game.c render.c gcc main.c game.c render.c -o tetris.exe -lgdi32 -luser32 -mwindows-lgdi32和-luser32是必须的GDI 绘图和窗口消息都靠这两个库。-mwindows告诉链接器生成 GUI 子系统程序不加的话运行时会弹一个控制台窗口在背后。这三个参数少一个都跑不起来是命令行编译最常见的翻车点。4.2 运行时的窗口尺寸窗口客户区尺寸要等于BOARD_COLS * CELL_SIZE宽、BOARD_ROWS * CELL_SIZE高再加上分数显示区域。CreateWindow传的是窗口总尺寸包含标题栏和边框所以要用AdjustWindowRect换算否则棋盘会被裁掉一部分。// main.c RECT rc { 0, 0, BOARD_COLS * CELL_SIZE, BOARD_ROWS * CELL_SIZE 40 }; AdjustWindowRect(rc, WS_OVERLAPPEDWINDOW, FALSE); HWND hwnd CreateWindow(szClass, LTetris, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, NULL, NULL, hInstance, NULL);40是给顶部分数文字留的高度。如果你改了CELL_SIZE或者加了“下一个方块预览”面板这个数字和宽度都要重新算。我见过有人把CELL_SIZE从 24 改成 32结果右边一列方块被窗口边框切掉查了半天以为是绘制 bug其实是窗口尺寸没同步。4.3 调试手段游戏逻辑的 bug 大多藏在“边界条件”里比如贴左墙旋转、触底瞬间按键、连续消行。调试时最有效的手段是在IsValidPosition里加临时日志把row、col、rotation和返回值打出来复现一次异常操作看哪一步判定和预期不符。// 临时调试输出定位碰撞问题后删掉 #ifdef _DEBUG char buf[128]; sprintf(buf, pos r%d c%d rot%d valid%d\n, row, col, rotation, result); OutputDebugStringA(buf); #endifOutputDebugStringA的输出在 Visual Studio 的“输出”窗口能看到不弹控制台适合 GUI 程序。用 MinGW 的话可以配合 DebugView 这类工具查看。这个习惯建议保留比到处printf干净得多。5. 常见问题排查那些让方块“不听话”的坑5.1 画面闪烁严重现象方块每下落一格整个画面闪一下眼睛很难受。原因是没有做双缓冲WM_PAINT里直接用BeginPaint拿到的 DC 逐格绘制背景擦除和前景绘制之间有时间差。解决办法就是第 2 章里那段CreateCompatibleDC加BitBlt的双缓冲写法先在内存位图上画完再一次性贴到屏幕。另外InvalidateRect的第三个参数传FALSE表示不擦背景也能减少一次闪烁源。5.2 按键没反应或连发太快现象按方向键方块不动或者按住不放时方块飞一样地移动。原因是WM_KEYDOWN会随键盘自动重复不断触发而WM_CHAR又只对可见字符有效。解决办法是在WM_KEYDOWN里处理方向键同时用一个keyRepeat标志或者判断lParam的第 30 位上一次按键状态来过滤自动重复。简单粗暴的做法是接受自动重复但把定时器间隔调大让按键移动和自动下落的速度拉开差距。5.3 方块贴墙旋转后卡死现象方块贴着左墙或右墙按旋转键方块消失或者位置错乱。原因是旋转后的新形态超出了棋盘边界而代码只判断了“是否重叠”没判断“是否越界”或者越界后没有回退。解决是在旋转前保存旧的rotation旋转后调IsValidPosition不合法就恢复旧值。进阶做法是加 wall kick尝试左右偏移一格再检测。5.4 消行后分数不对或行没消干净现象一次消两行分数只加了一次或者消行后上面还有残留。原因通常是消行循环里没有r回退重查导致连续满行只处理了最下面一行分数计算用了if而不是按lines分支。解决是照第 3 章ClearLines的写法消一行后r重新检查当前索引分数用switch或三元表达式按消行数给不同分值。5.5 游戏结束后还能操作现象方块堆到顶了游戏结束提示出来了但按方向键还能移动最后一块。原因是GameTick里判断了gameOver直接返回但HandleKey里没判断。解决是在按键处理函数开头加一句if (gameOver) return;或者在WndProc的WM_KEYDOWN分支里统一拦截。这个坑很小但漏掉的话玩家能靠“死后操作”把方块挪出屏幕画面会很诡异。6. 二次开发改造从能跑到好用我一般这么下手拿到这份源码第一件事不是急着加功能而是先跑通、再定位、最后动手。我的习惯是先编译运行确认基础玩法正常然后在game.h里把常量集中看一遍搞清楚哪些是“改了就生效”的旋钮。CELL_SIZE控制视觉大小SetTimer的间隔控制速度shapes数组控制方块种类ClearLines里的分值控制计分规则。这四个点覆盖了 80% 的改造需求。加“下一个方块预览”是最典型的练手改造涉及状态、逻辑、绘制三层。先在game.h加一个Piece nextPieceSpawnPiece里把nextPiece赋给current再随机生成新的nextPiece然后在render.c里划一块右侧区域用同样的DrawCell逻辑画nextPiece的掩码最后把窗口宽度加上预览区宽度。这个改造走一遍你就把“数据怎么流到屏幕”这条链路彻底摸清了。加“暂停”功能更简单一个int paused标志GameTick开头判断WM_KEYDOWN里用空格键切换。但要注意暂停时定时器还在跑只是逻辑不推进恢复时不会出现“时间跳跃”。加“最高分记录”则需要文件读写用fopen存一个整数到本地文件启动时读、游戏结束时写注意路径用相对路径或者GetModuleFileName拼绝对路径别写死盘符。改配色是最没有风险、反馈最快的改造。render.c里每个方块的颜色通常是一个COLORREF数组RGB(r, g, b)三个分量随便调。我建议先改这个找手感看着自己调的颜色一块块落下来比改逻辑有成就感得多。有一个教训我印象很深早期改这类项目我总喜欢直接在WndProc里加逻辑觉得“就几行放哪不是放”。结果加了三四个功能后WndProc变成两百多行改一个按键要上下翻半天还改出过一次switch漏break导致WM_PAINT被误触发、画面疯狂重绘的事故。从那以后我每次动这类 Win32 项目都强制先把新功能归到game.c或render.c的独立函数里WndProc只做分发一行都不多写。这个习惯帮我省了太多回头改代码的时间。希望这份拆解能帮你少走几步弯路把这块会转的方块改成你自己的样子。本文还有配套的精品资源点击获取