C语言俄罗斯方块源码拆解:Win32消息循环与GDI双缓冲实践 简介这是一份基于 Windows API、使用 C 语言编写的俄罗斯方块完整工程面向刚接触 Windows 编程、希望借助小型项目巩固 C 语言基础与窗口消息机制的初学者。源码带有较完整注释涵盖游戏主循环、方块绘制、键盘响应和状态切换等环节可对照理解左右键控制方向、下键加速、上键变形、空格暂停等操作背后的实现方式也便于熟悉 VC6.0 下的工程组织与资源文件调用对想从控制台程序过渡到图形界面开发的读者较为友好。压缩包采用 rar 格式共 28 个文件约 1.12MB主要包含 cpp 与 h 源码、rc 资源脚本、ico 图标及 exe 可执行文件另有 dsp/dsw 工程文件和说明文本目录结构清晰适合按模块查阅。已有 4515 人学习下载可作为 Windows 编程入门练手案例通过阅读注释、编译调试和修改参数逐步掌握消息循环、窗口绘制、键盘控制与简单游戏状态管理等知识点。1. 一个能跑起来的最小窗口为什么这份 C 语言俄罗斯方块源码值得拆前几天有人发来一个压缩包说这个 C 语言写的俄罗斯方块跑起来窗口一闪就白屏。我解压看一眼Windows编程该有的骨架都在——WinMain、WndProc、GDI 画格子、SetTimer 驱动下落唯独 WM_PAINT 里没做双缓冲画面每帧都在闪。修掉之后我把工程整个拆了一遍它不依赖任何引擎和第三方库一个 16 位掩码数组就描述完七种方块一个消息循环就撑起整个游戏节奏。这体量特别适合两类人刚学完 C 语法、想找个改得动的完整程序练手的以及要给老工具加界面、需要复习 Win32 消息与 GDI 绘图的老手。下面按“先跑通、再拆结构、最后动手改”的顺序走一遍。2. 工程骨架从 WinMain 到消息循环120 行决定后面所有事很多 C 语言小游戏死在第一步窗口能出来但键盘没反应或者一拖动就花屏。原因基本不在玩法逻辑而在骨架层没搭对——窗口类字段填漏、消息循环里做了重活、定时器当成帧同步用。这份源码把骨架单独放在 main.c 里约 120 行剩下三百多行全是规则界限很清楚。2.1 文件怎么拆四个 .c 加一个头文件文件职责大致行数main.cWinMain、窗口类注册、消息循环、WndProc120game.c棋盘状态、方块生成、碰撞、消行、计分300render.c双缓冲、格子绘制、面板文字220input.c按键到动作的映射80tetromino.h常量、结构体、对外函数声明60单文件也能塞下七八百行但改起来痛苦改个配色要往下滚两百行调个下落速度又要在渲染代码里翻。拆开之后规则层不 include 任何 windows.hgame.c 只依赖 stdint.h 和 string.h这意味着玩法逻辑可以脱离 Win32 单独测试——我把棋盘逻辑抽出来在一个控制台程序里跑过几百局确认消行和下移没问题再回头调界面省掉很多来回编译的时间。还有个细节值得说所有常量集中在 tetromino.h棋盘宽高、格子像素、定时器 ID 都在那儿。后面想把它改成 12×24 的场地或者做个双人对战改一个宏就够不用满工程搜 10 和 20。2.2 注册窗口类字段必须一次填对窗口类里最容易被忽略的是 hbrBackground。留成 NULL系统不会替你擦背景第一次 WM_PAINT 覆盖不到的区域就残留脏像素看起来像白屏。另一个是 CS_HREDRAW | CS_VREDRAW少了它窗口拉伸时内容不会重绘拖一下就糊成一片。/* main.c —— 窗口类注册与创建字段填错会让 CreateWindowEx 直接返回 NULL */ static const TCHAR kClass[] TEXT(TetrisWnd); int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmd, int show) { WNDCLASSEX wc { 0 }; wc.cbSize sizeof(wc); /* 不填这个注册必失败 */ wc.style CS_HREDRAW | CS_VREDRAW; /* 尺寸变化时整窗重绘 */ wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)GetStockObject(BLACK_BRUSH); wc.lpszClassName kClass; if (!RegisterClassEx(wc)) return 1; /* 客户区要正好 CLIENT_W x CLIENT_H所以先反推带边框的外框尺寸 */ RECT rc { 0, 0, CLIENT_W, CLIENT_H }; AdjustWindowRect(rc, WS_OVERLAPPEDWINDOW, FALSE); HWND hwnd CreateWindowEx(0, kClass, TEXT(Tetris), WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, NULL, NULL, hInst, NULL); if (!hwnd) return 2; ShowWindow(hwnd, show); UpdateWindow(hwnd); return msg_loop(hwnd); }AdjustWindowRect 那段是新手最容易漏的。直接传 CLIENT_W 当窗口宽客户区会被边框和标题栏吃掉画到最右边一列方块就被裁掉半截还以为是碰撞检测写错了。参数含义上第三个 FALSE 表示没有菜单栏如果工程里加了菜单这个值要改成 TRUE否则算出来的高度差一截。2.3 消息循环与固定步长定时器只负责叫醒把 SetTimer 的间隔直接当游戏速度是这类项目里最常见的架构失误。16ms 的定时器在系统负载下可能拖到 30ms方块下落的实际速度就跟着机器性能走同一份代码在别人的电脑上手感完全变了。正确的分层是定时器只负责周期性叫醒程序真正的逻辑推进由游戏内部的时间累加器决定。/* main.c —— 16ms 只是心跳不是帧率 */ static int msg_loop(HWND hwnd) { MSG msg; SetTimer(hwnd, TIMER_TICK, 16, NULL); while (GetMessage(msg, NULL, 0, 0) 0) { TranslateMessage(msg); DispatchMessage(msg); } KillTimer(hwnd, TIMER_TICK); return (int)msg.wParam; }/* game.c —— 时间累积掉一格与画面刷新彻底解耦 */ void game_tick(Game *g, uint32_t now_ms) { uint32_t delta now_ms - g-last_tick; g-last_tick now_ms; if (g-paused || g-over) return; if (delta 200) delta 200; /* 拖动窗口或断点调试后别一次补几百步 */ g-acc_ms delta; while (g-acc_ms g-drop_interval) { g-acc_ms - g-drop_interval; if (!try_move(g, 0, 1)) { /* 下不去了锁定、消行、发新块 */ lock_piece(g); spawn_next(g); } } }drop_interval 是“下落一格需要多少毫秒”level 越高这个值越小常见做法是 800 - (level - 1) * 70再压一个 100ms 的下限否则高等级会快到人类反应不过来。acc_ms 是余量delta 截断那行是后悔药调试断点停两秒回来不加限制就会一次性下落几百格方块直接穿模。这个值按场景定200 是个安全数。2.4 WndProc 与编译命令/* main.c —— 消息分发的全部内容注意 WM_TIMER 里只做 tick 和请求重绘 */ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { static Game g; /* 单窗口够用多窗口要挂到 GWLP_USERDATA */ switch (msg) { case WM_CREATE: game_init(g); return 0; case WM_TIMER: if (wp TIMER_TICK) { game_tick(g, (uint32_t)GetTickCount64()); InvalidateRect(hwnd, NULL, FALSE); /* FALSE交给双缓冲自己擦 */ } return 0; case WM_KEYDOWN: input_handle(g, wp, lp); return 0; case WM_PAINT: paint(hwnd, g); return 0; case WM_DESTROY: KillTimer(hwnd, TIMER_TICK); PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wp, lp); }InvalidateRect 第三个参数传 FALSE意思是“别帮我擦背景我自己会画满”。传 TRUE 的话系统先刷一遍底色再调用你的绘制中间那一瞬就是闪烁的来源双缓冲做了也白做。编译只有一条命令不依赖任何第三方库# MinGW-w64-mwindows 让 exe 不弹控制台黑框 gcc -O2 -Wall -Wextra -mwindows \ main.c game.c render.c input.c \ -o tetris.exe -lgdi32 -luser32 # MSVC 等价写法 cl /O2 /W4 /DUNICODE /D_UNICODE main.c game.c render.c input.c /link user32.lib gdi32.lib-lgdi32是给 CreateCompatibleDC、BitBlt 这些绘图函数用的-luser32管窗口和定时器。两个链接顺序反了在某些 MinGW 版本上会报 undefined reference遇到了把 gdi32 挪到最后就行。3. 核心数据结构7 种方块、16 位掩码与消行下移的完整链路规则层是整个工程最值得读的部分。它没有炫技但每一处都能看出作者踩过坑——方块不是二维数组旋转不是矩阵乘法消行用了 memmove 而不是手写循环。这一章把这几个决定逐个拆开。3.1 用一个 16 位整数存一个方块七种方块每种四个旋转态如果老老实实写成int shape[7][4][4][4]就是 448 个整数占 1.7KB代码里全是嵌套循环。用 4×4 的位图来存一个 uint16_t 就够了每行 4 位最高位对应左上角。/* tetromino.h —— 最高位是 (0,0)每行 4 位从 MSB 往 LSB 排 */ #define CELL_BIT(r, c) (0x8000u ((r) * 4 (c))) typedef struct { int x, y; /* 包围盒左上角在棋盘坐标系里的位置可以是负数 */ int type; /* 0..6对应 I O T S Z J L */ uint16_t mask; /* 当前旋转态 */ } Piece; /* 出生姿态数组下标就是 type */ static const uint16_t SPAWN_MASK[7] { 0x0F00, /* I行1 1111 */ 0x0660, /* O行0 行1 0110 */ 0x0E40, /* T行1 1110行2 0100 */ 0x06C0, /* S行0 0110行1 1100 */ 0x0C60, /* Z行0 1100行1 0110 */ 0x0E80, /* J行1 1110行2 1000 */ 0x0E20 /* L行1 1110行2 0010 */ };好处不只是省内存。判断一个格子有没有方块变成一次位与(mask CELL_BIT(r, c)) ! 0没有数组寻址没有越界风险。整个方块平移只要改 x 和 y不用遍历任何数据。渲染时也只遍历这 16 个 bit空行直接跳过。代价是可读性0x0E40这串数字谁也看不出来是个 T。所以工程里配了一个调试函数把它按 4×4 打印成字符画改形状表之前先 dump 一遍核对/* 只在调试时调用把掩码画出来看比对着十六进制猜快十倍 */ void dump_mask(uint16_t m) { for (int r 0; r 4; r) { for (int c 0; c 4; c) putchar((m CELL_BIT(r, c)) ? # : .); putchar(\n); } }3.2 碰撞检测一次遍历 16 格处理三种越界这是全工程调用最频繁的函数每帧都在跑。逻辑本身很短但边界条件有三个左右出界、底部出界、顶部溢出。前两个必须判定为碰撞第三个必须放行。/* game.c —— 返回 1 表示撞了by 0 的部分放行方块能从顶部之外出生 */ int collide(const uint8_t board[BOARD_H][BOARD_W], const Piece *p) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (!(p-mask CELL_BIT(r, c))) continue; int bx p-x c; int by p-y r; if (bx 0 || bx BOARD_W) return 1; /* 左右墙 */ if (by BOARD_H) return 1; /* 地板 */ if (by 0) continue; /* 顶部之外先不管 */ if (board[by][bx]) return 1; /* 撞到已固定的块 */ } } return 0; }为什么顶部要放行因为新方块出生时 y 是 -1 或 -2让它的可见部分刚好压在井口这样入场看起来自然。如果这里也判碰撞出生瞬间就直接 game over 了。相对的游戏结束的判定要单独做新块生成后调一次 collide撞了才算死。board 的类型是 uint8_t 而不是 int一是省内存10×20 只有 200 字节一张位图能塞一百个这样的棋盘二是 memset 清行快。0 表示空1~7 表示方块类型对应的颜色索引8 留给“垃圾行”——后面做双人对战时加进来很顺手。3.3 旋转与简化踢墙运行时算比写死四张表更不容易错很多教程的做法是给每种方块手写四个旋转态。我试过写完之后 dump 出来一看S 和 Z 的第二态位置偏移了一列形状没错但转起来会平移一格手感很怪查了半天才发现是抄表抄串行。运行时旋转更省事就是一次转置加反转/* game.c —— 顺时针旋转 90 度新(r,c) 旧(3-c, r) */ static uint16_t rot_cw(uint16_t m) { uint16_t out 0; for (int r 0; r 4; r) for (int c 0; c 4; c) if (m CELL_BIT(3 - c, r)) out | CELL_BIT(r, c); return out; }16 次循环现代 CPU 上快到测不出来换来的好处是四个态永远自洽不可能出现抄错。逆时针就是连续转三次或者把公式里的 r、c 对调。真正影响手感的是踢墙。贴着墙或贴着别的方块旋转时如果只试原始位置会有一半的旋转被吃掉玩起来像“卡住了”。完整的 SRS 踢墙表有五组偏移还分 I 和 JLSTZ 两套写全了要几十行。这个工程用了简化版只做水平方向/* game.c —— 依次尝试五个水平偏移第一个不撞的就采用 */ static const int KICK_DX[5] { 0, -1, 1, -2, 2 }; int piece_rotate(Game *g, int dir) { uint16_t m g-cur.mask; int times (dir 0) ? 1 : 3; while (times--) m rot_cw(m); for (int i 0; i 5; i) { Piece t g-cur; t.mask m; t.x KICK_DX[i]; if (!collide(g-board, t)) { g-cur t; return 1; } } return 0; /* 五个位置都不行就放弃这次旋转保持原状 */ }两格的偏移量足够应付绝大多数贴墙旋转唯一缺的是“贴地旋转”时向上顶一格的能力那需要 y 方向踢墙。要不要补看你想做到多接近现代手感休闲版本这样已经够用写起来还不到十行。3.4 消行与下移memmove 那行的字节数最容易写错消行的直觉写法是两层循环搬数据写得对但啰嗦。用 memmove 一次搬完整块更清楚也更快。/* game.c —— 从下往上扫满行就把上面所有行整体下移一格 */ int board_clear_lines(uint8_t board[BOARD_H][BOARD_W]) { int cleared 0; for (int r BOARD_H - 1; r 0; --r) { int full 1; for (int c 0; c BOARD_W; c) if (!board[r][c]) { full 0; break; } if (!full) continue; /* 第 0..r-1 行整体下移一格共 r 行 */ memmove(board[1][0], board[0][0], sizeof(uint8_t) * BOARD_W * r); memset(board[0][0], 0, sizeof(uint8_t) * BOARD_W); cleared; r; /* 同一行号已经是上一行的内容得原地再查一次 */ } return cleared; }三个容易翻车的点。第一memmove 的长度是BOARD_W * r不是BOARD_W * (r 1)多搬一行就把当前这行也带下去了。第二必须用 memmove 而不是 memcpy源和目标区间重叠memcpy 的行为是实现相关的。第三搬完要r抵消循环里的--r否则连着消两行时上面那行会被跳过——四连消的分数就算少了这个 bug 在测试时很难一眼看出来。返回值是本次消掉的行数交给计分/* 经典计分单 100、双 300、三 500、四 800再乘当前等级 */ static void add_score(Game *g, int lines) { static const int SCORE[5] { 0, 100, 300, 500, 800 }; if (lines 0 || lines 4) return; g-score SCORE[lines] * g-level; g-total_lines lines; g-level 1 g-total_lines / 10; /* 每消十行升一级 */ }3.5 锁定延迟那 500 毫秒决定了手感方块落地后立刻锁死是最劝退的设计。玩家永远在“差一点点能挪过去”的瞬间被锁住。现代俄罗斯方块都有锁定延迟触底后给一段宽限时间期间还能左右移动甚至旋转时间到了才真正固定。/* game.c —— 触底后进入宽限期移动或旋转成功就重置计时 */ void update_lock(Game *g, uint32_t now_ms) { if (try_move(g, 0, 1)) { /* 还能往下取消宽限 */ g-lock_start 0; return; } if (g-lock_start 0) { g-lock_start now_ms; /* 刚触底开始计时 */ return; } if (now_ms - g-lock_start LOCK_DELAY_MS) { /* 常见值 500 */ lock_piece(g); spawn_next(g); g-lock_start 0; } }LOCK_DELAY_MS 取 500 是行业惯例短了没感觉长了一局时间被拖得很慢。还有个细节宽限期内如果玩家成功移动要重置 lock_start这叫“移动重置”是进阶手感的一部分但重置次数要限制不然玩家可以无限拖延。这个工程的做法最朴素——只重置一次用一个 move_reset_used 标志位控制够用且不会出现无限拖的漏洞。4. GDI 渲染与输入双缓冲、格子绘制和按键映射这三件事规则层跑通之后剩下的全是“怎么让它看起来像个游戏”。GDI 是老 API画方块这种矩形堆叠反而比 D3D 省事但有两个固有毛病不双缓冲就闪字体不设透明就带底色。这一章把这两点连同按键处理一起说清。4.1 内存 DC 加 BitBlt白屏和闪烁的真正来源WM_PAINT 里直接往窗口 DC 上画系统会先发 WM_ERASEBKGND 擦背景再让程序画两帧之间被用户看到就是闪烁。拖动窗口、切换焦点、缩放时尤其明显。解决办法是把一整帧先在内存位图上画完再一次性拷过去。/* render.c —— 一帧先在内存里拼好最后只做一次 BitBlt */ static void paint(HWND hwnd, const Game *g) { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); RECT rc; GetClientRect(hwnd, rc); int cw rc.right - rc.left; int ch rc.bottom - rc.top; HDC mem CreateCompatibleDC(hdc); HBITMAP bmp CreateCompatibleBitmap(hdc, cw, ch); HBITMAP old (HBITMAP)SelectObject(mem, bmp); fill_background(mem, cw, ch); draw_well(mem, g); /* 井壁 已固定的方块 */ draw_piece(mem, g); /* 当前下落块 */ draw_panel(mem, g, cw, ch); /* 分数、等级、下一个 */ BitBlt(hdc, 0, 0, cw, ch, mem, 0, 0, SRCCOPY); SelectObject(mem, old); /* 一定先还原否则位图删不掉 */ DeleteObject(bmp); DeleteDC(mem); EndPaint(hwnd, ps); }CreateCompatibleBitmap 每帧调一次确实浪费一张 400×600 的位图反复创建销毁任务管理器里能看到 GDI 对象数上下跳。正式版本应该在 WM_SIZE 里创建一次缓存起来尺寸变了才重建。这里保留每帧创建是因为它绝对安全——不会出现句柄泄漏后画面变黑这种玄学问题等玩法稳定了再优化。SelectObject 那行的顺序是血泪经验必须把旧位图选回去再 DeleteObject否则这张位图还被 DC 引用着删除失败跑十分钟就是几百个 GDI 对象泄漏最后整个窗口画不出来。4.2 画一个方块底色加两道边就够了方块不需要贴图。三层矩形——填充、左上高光、右下暗边——就能出来很干净的立体感。/* render.c —— CELL 是格子边长留 2px 缝让方块之间有分界 */ static void draw_cell(HDC hdc, int px, int py, COLORREF base) { RECT r { px, py, px CELL - 2, py CELL - 2 }; HBRUSH b CreateSolidBrush(base); FillRect(hdc, r, b); DeleteObject(b); HPEN pen CreatePen(PS_SOLID, 1, blend(base, RGB(255,255,255), 45)); HGDIOBJ oldPen SelectObject(hdc, pen); HGDIOBJ oldBrush SelectObject(hdc, GetStockObject(NULL_BRUSH)); MoveToEx(hdc, r.left 1, r.bottom - 1, NULL); LineTo (hdc, r.left 1, r.top 1); LineTo (hdc, r.right - 1, r.top 1); SelectObject(hdc, oldBrush); SelectObject(hdc, oldPen); DeleteObject(pen); }SelectObject 换 NULL_BRUSH 是关键不然 LineTo 画出来的折线会被当前画刷填充成一个三角形。blend 是个自己写的混色函数把底色往白色方向提亮 45%比硬编码高光色统一。type形状底色 RGB高光提亮0I(41, 197, 246)45%1O(247, 208, 56)45%2T(168, 96, 234)45%3S(86, 200, 110)45%4Z(232, 78, 78)45%5J(66, 118, 232)45%6L(240, 148, 60)45%8垃圾行(120, 120, 128)30%调色这块纯属个人偏好改 tetromino.h 里那张表就行。唯一要注意的是别选饱和度太高的纯色GDI 没有抗锯齿纯红纯绿在 24px 的格子上边缘会很扎眼。4.3 面板文字SetBkMode 不设就是一块色块右侧面板要显示分数、等级和下一个方块。用 TextOut 或 DrawText 都行但必须先设透明背景否则每段文字后面都跟着一个和窗口底色不一样的矩形。/* render.c —— 文字面板注意 TRANSPARENT 与字体创建的位置 */ void draw_panel(HDC hdc, const Game *g, int cw, int ch) { int px BOARD_W * CELL 24; SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, RGB(220, 220, 230)); HFONT font CreateFont(-20, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, TEXT(Consolas)); HGDIOBJ oldFont SelectObject(hdc, font); TCHAR buf[64]; _stprintf_s(buf, 64, TEXT(SCORE %u), g-score); TextOut(hdc, px, 16, buf, (int)_tcslen(buf)); _stprintf_s(buf, 64, TEXT(LEVEL %d), g-level); TextOut(hdc, px, 48, buf, (int)_tcslen(buf)); TextOut(hdc, px, 96, TEXT(NEXT), 4); draw_preview(hdc, g, px, 120); SelectObject(hdc, oldFont); DeleteObject(font); }CreateFont 每帧创建也不理想可以提到 WM_CREATE 里存成一个全局 HFONTWM_DESTROY 里删掉。用 Consolas 是因为它是等宽字体分数从 999 跳到 1000 时数字不会左右抖。4.4 按键映射别把 WM_KEYDOWN 的重复消息当单击/* input.c —— 只处理按下方向键的自动重复交给系统 */ void input_handle(Game *g, WPARAM vk, LPARAM lp) { if (g-over vk ! VK_RETURN) return; switch (vk) { case VK_LEFT: try_move(g, -1, 0); break; case VK_RIGHT: try_move(g, 1, 0); break; case VK_DOWN: soft_drop(g); break; case VK_UP: case X: piece_rotate(g, 1); break; case Z: piece_rotate(g, -1); break; case VK_SPACE: hard_drop(g); break; case P: g-paused !g-paused; break; case VK_RETURN: if (g-over) game_init(g); break; } }Windows 会对按住的方向键持续发 WM_KEYDOWN间隔由系统键盘重复速率决定一般是 30ms 一次这就是天然的 DAS延迟自动移位。但软降用这个重复速率会太慢所以soft_drop里应该一次掉一格并且把 acc_ms 清零让它和定时器驱动的下落互不干扰。硬降则是直接 while 循环往下推到底然后立刻锁定。5. 避坑与排查白屏、穿墙、按键连发和计时漂移的现场记录这一章全是实际撞出来的。每个现象背后都有一个很具体的 API 行为或者一行写错的字节数按“现象 → 原因 → 解决”记下来遇到同类本文还有配套的精品资源点击获取