C语言结合Windows API手写俄罗斯方块:消息循环与GDI绘制实战 简介一份用C语言结合Windows API实现的俄罗斯方块完整源码工程恰好适合初学Windows编程或想用项目巩固C语法的学习者。作者从DOS、SDL、OpenGL一路调研后最终选用Windows API在VC6.0下完成开发工程包含游戏绘制、消息循环、定时器、方块变形与碰撞检测等模块关键函数均附有注释便于理解坐标变换和事件驱动的编程思路。压缩包共28个文件其中以cpp源文件、h头文件为主另有ico图标、rc资源脚本、可直接运行的exe及VC6工程配置文件结构完整整体仅1.12MB非常轻量。目前已有4514人学习适合作为C语言综合实战或Windows API入门的参考素材。1. 项目定位不用图形库用 C 语言和 Windows API 徒手写俄罗斯方块C 语言学完指针和结构体之后最让人心痒的就是做点能用鼠标看见的东西。这个基于 Windows API 的 C 语言俄罗斯方块源码正好补上这最后一课。它不依赖图形库用 Win32 窗口、GDI 绘制和标准 C 代码搭出一个可玩的游戏包含方块生成、旋转、左右移动、加速下落、消行计分这些完整逻辑。适合正在学 C 语言、又好奇 Windows 编程怎么跑起来的人也适合想复习消息循环、回调函数和二维数组的熟手。下面会从架构、代码、编译和二次开发几个角度把这个项目拆开让你拿到源码后能直接跑起来也知道每一段在干什么。2. 架构设计一条消息循环如何驱动整个游戏状态机2.1 为什么选 Windows APIC 语言跑图形界面的低成本路线俄罗斯方块不是一个性能敏感的应用它最核心的是“定时推进 按键响应”。只要有一个窗口能接收键盘消息、能在窗口上画色块就够了。而 Windows API 正好是 C 语言在 Windows 上最原生、依赖最少的图形方案。它不需要安装第三方库编译时链接 user32、gdi32 这些系统库就能跑。相比之下用控制台来做每次画界面都要处理光标定位和缓冲区刷新性能和闪烁问题反而更麻烦用图形库的话又会引入一堆环境配置成本。所以这套源码的主路径是WinMain 注册窗口类 - 创建窗口 - 进入消息循环游戏更新放在 WM_TIMER 里绘制放到 WM_PAINT 里。这种结构非常符合 Windows 编程的经典套路数据在回调函数之间共享界面随着窗口消息被动刷新。#include windows.h LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASS wc {0}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName TetrisWin; RegisterClass(wc); HWND hwnd CreateWindowEx( 0, TetrisWin, C 语言俄罗斯方块, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 360, 520, NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }这是标准 Win32 程序骨架。WNDCLASS 里最关键的是lpszClassName和lpfnWndProc前者让 CreateWindowEx 能找到窗口类后者是窗口过程承担所有消息处理。消息循环中 GetMessage 在收到 WM_QUIT 时返回 0程序退出TranslateMessage 把键盘消息转成字符消息DispatchMessage 再把消息分发给 WndProc。很多第一次接触的人以为 WinMain 之后会自动创建窗口其实创建窗口的代码必须自己写。2.2 目录结构游戏逻辑、输入事件、渲染分别放哪里这类 C 语言小游戏一般是单文件或两三个 .c 文件因为逻辑量不大。比较典型的组织方式如下文件职责main.cWinMain、窗口过程、消息分发game.h数据结构、方块形状表、函数声明game.c方块旋转、碰撞检测、消行、计分draw.cGDI 绘制、双缓冲、格子渲染实际解压后可能只有一个tetris.c也完全说得通。C 语言不像 Java 那样强制类和文件一一对应所以先看game.h里的结构体再看game.c里的函数最后回来看main.c怎么把按键和定时器串起来就能把整个框架盘活。2.3 状态机菜单、游戏中、暂停、结束一个可玩的俄罗斯方块必然有“游戏进行中”“暂停”“游戏结束”这几个状态。这些状态如果没有集中管理很容易在按键、定时器、绘制三个模块里出现互相打架的情况。这套源码常见做法是定义一个简单的枚举再用一个全局变量保存当前状态。typedef enum { STATE_MENU, STATE_PLAYING, STATE_PAUSED, STATE_OVER } GameState; GameState g_state STATE_MENU;所有按键和定时器分支第一步都是读g_state不是每个函数直接修改。比如空格键按下时只有STATE_PLAYING才执行硬降STATE_PAUSED时按空格恢复。这样做的另一个好处是绘制函数可以根据状态显示不同的文字不用在多个地方重复判断。STATE_MENU和STATE_OVER的区别要分清楚菜单是开局前结束是死亡后。很多简化版把两者混成一个导致按回车重启时处理混乱。2.4 定时器驱动用 WM_TIMER 而不是 Sleep新手最容易犯的错误是把“下落一步”写成Sleep(500); moveDown();结果窗口整个卡住鼠标拖不动这就是没有用消息循环的典型翻车现场。俄罗斯方块需要一个“每隔一定时间自动往下走一步”的机制正确做法是在窗口创建后调用 SetTimer然后在 WM_TIMER 消息里推进游戏逻辑。case WM_CREATE: SetTimer(hwnd, TIMER_ID, 500, NULL); break; case WM_TIMER: if (g_state STATE_PLAYING) { moveDown(); InvalidateRect(hwnd, NULL, FALSE); } break;SetTimer 的第一个参数是窗口句柄第二个是定时器 ID第三个是毫秒间隔第四个是回调函数传 NULL 表示把消息发给窗口过程。这样每 500 毫秒窗口就会收到一个 WM_TIMER我们只在游戏进行中调用 moveDown()然后让窗口区域失效触发 WM_PAINT 重绘。TIMER_ID 要用一个整数常量例如 1。如果要做等级加速需要修改的是 SetTimer 的第三个参数而不是把 Sleep 放到循环里。2.5 键盘与绘制消息分开处理在窗口过程里WM_KEYDOWN 负责处理方块的移动和旋转WM_PAINT 负责把所有内容画出来。游戏数据更新和渲染要分开这是整个项目最值得学习的点。按键时只改方块坐标不改绘制代码绘制时只读当前坐标和颜色不反向修改数据。case WM_KEYDOWN: switch (wParam) { case VK_LEFT: moveLeft(); break; case VK_RIGHT: moveRight(); break; case VK_UP: rotate(); break; case VK_DOWN: moveDown(); break; case VK_SPACE: hardDrop(); break; } InvalidateRect(hwnd, NULL, FALSE); break;注意 wParam 是虚拟键码VK_LEFT 对应左方向键VK_SPACE 是空格。这里没有直接调用画格子的函数而是统一用 InvalidateRect 要求系统稍后重绘。因为 WM_PAINT 的绘制时机由系统决定如果把绘制直接放在按键里窗口被遮挡再恢复时内容会丢失。2.6 一个完整帧从输入到屏幕刷新把上面的内容串起来整个项目的一帧大概长这样用户在窗口按下方向键Windows 把 WM_KEYDOWN 投递到消息队列。消息循环取出消息DispatchMessage 调用 WndProc。WndProc 根据消息种类处理按键更新坐标定时器推进下落。任何数据变化后调用 InvalidateRect 丢一块“脏区域”。系统觉得时机合适发送 WM_PAINT窗口过程执行真正的 GDI 绘制。BeginPaint 获取画布画完 EndPaint 释放游戏画面更新。一个容易让人懵的地方是WM_TIMER 和 WM_PAINT 不是同一时间触发的。定时器只管“该掉一格了”绘制是另一条激活路径。所以所有更新逻辑都只改数据绘制永远从全局状态里读数据。这个解耦思路和现代游戏引擎里的 update/render 分离是同一套逻辑。3. 核心逻辑拆解方块旋转、碰撞检测与消行的三件套3.1 方块数据用预定义旋转态还是位编码俄罗斯方块有 7 种基本形状每种形状可以用 4x4 的小格子矩阵描述。常见做法是开一个四维数组形状编号 X 旋转状态 X 行 X 列。虽然看上去浪费空间但 4x4 只占 16 字节整个表也就 4 * 4 * 4 * 7 个 int完全是小意思。下面用 I 形方块作为例子展示这种定义方式#define ROT_COUNT 4 #define CELL_SIZE 4 static const int I_SHAPE[ROT_COUNT][CELL_SIZE][CELL_SIZE] { { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,1,0,0}, {0,1,0,0}, {0,1,0,0}, {0,1,0,0} }, { {0,0,0,0}, {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} } };这里用 4x4 矩阵表示方块是为了让旋转有统一算法。每个形状预先存好 4 种旋转态旋转时只要把下标从rot改成(rot 1) % 4不需要做矩阵变换代码简单很多。代价是手动写形状表有点费眼睛而且容易抄错如果抄错就会发现某个方块转起来“忽大忽小”。我一般写完会先用一行 printf 把形状表打印出来对一遍。ROT_COUNT 必须是 4即使 O 形方块旋转后长得一样也要占 4 个状态位否则旋转到第 4 次时数组越界。另一种做法是只用一个 3x3 矩阵通过旋转时把 i,j 映射为 j, size-1-i 来计算新坐标。但在这个源码里预定义 4 种旋转态更常见因为运行时代码简单不容易出现越界问题。3.2 旋转试探后再确认加一点踢墙旋转在数据上只是把 currentRot 加一但真正难的是“旋转后位置是否出界”。因为每个方块在 4x4 矩阵里并不是占用全部格子旋转中心不同看起来可能像往旁边挪了一格。所以旋转函数的标准写法是先试探性旋转到新状态检查能不能放能放则确认不能放则恢复。int tryRotate(GameBoard *board, ActiveBlock *block) { int oldRot block-rot; int newRot (oldRot 1) % ROT_COUNT; int dx 0; block-rot newRot; while (isColliding(board, block)) { dx; block-x - dx; // 尝试向左推 if (!isColliding(board, block)) return 1; block-x dx; dx; block-x dx; // 尝试向右推 if (!isColliding(board, block)) return 1; block-x - dx; block-rot oldRot; return 0; } return 1; }这个函数先记录旧的旋转态然后把方块旋转到下一态用 isColliding 判断。如果新位置不能放就做“踢墙”把方块左右平移一两格尝试救回来。如果左移和右移都不行就恢复旧旋转态并返回 0。一个常见的简化做法是不做踢墙转不了就放弃但在贴边时手感和预期差距很大。block-x 是当前方块在游戏板中的列位置dx 的步长和方块矩阵大小有关。3.3 碰撞检测边界和堆叠方块共用一套判断碰撞检测的核心是把方块矩阵里的 1 映射到游戏板的行和列再判断目标格子有没有越界或已经被占用。int isCellOccupied(GameBoard *board, int row, int col) { if (col 0 || col BOARD_COLS) return 1; if (row BOARD_ROWS) return 1; if (row 0) return 0; return board-cells[row][col] ! 0; } int isColliding(GameBoard *board, ActiveBlock *block) { int r, c; for (r 0; r CELL_SIZE; r) { for (c 0; c CELL_SIZE; c) { if (block-shape[block-rot][r][c]) { int rr block-y r; int cc block-x c; if (isCellOccupied(board, rr, cc)) { return 1; } } } } return 0; }isCellOccupied 把所有“不能放”的情况统一返回 1列出界、底部出界、已有方块。row 0 返回 0 表示顶部的负行允许方块半截在外面这是为了方块开始生成时能露出一部分。isColliding 遍历当前旋转态的所有格子只要矩阵中为 1 的格子落进被占用位置就认为整块不能放。BOARD_ROWS 和 BOARD_COLS 一般定义为 20 和 10是游戏板大小。block-y 和 block-x 表示方块矩阵左上角在游戏板中的坐标。3.4 消行从底部往上扫命中就整行搬移消行是俄罗斯方块里最简单也最容易写错的逻辑。写错的表现是消了一行后上面的方块少搬了一行或者计数累计不对。正确顺序是从游戏板最底部一行向上找一旦发现整行都非 0就把这一行上方所有行整体下移一行再把最顶行清零。int clearLines(GameBoard *board) { int row, col; int cleared 0; for (row BOARD_ROWS - 1; row 0; row--) { int full 1; for (col 0; col BOARD_COLS; col) { if (board-cells[row][col] 0) { full 0; break; } } if (full) { for (int r row; r 0; r--) { for (col 0; col BOARD_COLS; col) { board-cells[r][col] board-cells[r - 1][col]; } } for (col 0; col BOARD_COLS; col) { board-cells[0][col] 0; } cleared; row; // 上面整体下移当前行需要重新判断 } } return cleared; }这个实现一眼能看懂碰到满行就把上方所有行往下搬最顶行补 0然后 row 加一重新检查移动下来的这一行避免漏判。20x10 的棋盘用这种方式完全不会卡。返回值是本次消掉的行数调用方可以据此加分。计分规则通常是 1 行 100 分2 行 300 分3 行 500 分4 行 800 分但计分表不是这个函数管的它只负责返回数字。3.5 绘制GDI 格子和双缓冲的取舍绘制是 Windows 编程里最容易被绕晕的部分。俄罗斯方块画面其实就是一个 20 行 10 列的网格每个格子画成不同颜色的实心矩形。直接在每个 WM_PAINT 里逐个画矩形会看到明显的闪烁所以这版源码用了一个内存画板先画到内存再一次 BitBlt 到窗口。void drawBoard(HDC hdc, GameBoard *board) { int r, c; for (r 0; r BOARD_ROWS; r) { for (c 0; c BOARD_COLS; c) { COLORREF color board-cells[r][c] ? RGB(66, 134, 244) : RGB(15, 15, 15); HBRUSH brush CreateSolidBrush(color); HBRUSH old SelectObject(hdc, brush); Rectangle(hdc, c * CELL_PIX, r * CELL_PIX, (c 1) * CELL_PIX - 1, (r 1) * CELL_PIX - 1); SelectObject(hdc, old); DeleteObject(brush); } } }drawBoard 每次画格子都创建一把画刷用完立刻删掉。如果不在 DeleteObject程序在长时间运行后会慢慢把 GDI 对象耗尽窗口会突然画不出来这是典型的资源泄漏。Rectangle 的最后两个参数减 1是为了让相邻格子之间留出 1 像素缝视觉效果更清楚。CELL_PIX 是每个格子的像素尺寸通常 20 或 22。如果你觉得窗口太小只要改 CELL_PIX 一个常量所有格子、区域、坐标都会跟着缩放。4. 编译与运行用 MinGW 还是 Visual Studio怎么改怎么调4.1 解压后先看什么文件清单和入口函数拿到这个源码包第一步不是急着点编译而是打开目录看有没有 README、.c、.h。最常见的三种情况只有一个 tetris.c 单文件有 main.c game.c draw.c 多文件或者带了一个 Visual Studio 工程文件。不管哪种入口函数一定是 WinMain窗口过程的名字可能是 WndProc 或 MainProc。用编辑器全局搜一下 WinMain 就能定位入口。如果你拿到的是纯 .c 文件建议自己建一个空项目或直接用命令行编译如果包里有 .sln 或 .vcxproj直接用 Visual Studio 打开后按 F5 能跑。多文件编译时要注意头文件里的声明和定义不要重复链接时每个 .c 文件都要传给 gcc漏一个就会报 undefined reference。4.2 用 MinGW 命令行编译gcc 一条命令跑通MinGW 是目前在 Windows 上最省事的 GNU 工具链。假设你的 gcc 在 PATH 里打开命令提示符进入源码目录依次执行gcc -stdc99 -Wall -mwindows main.c game.c draw.c -o tetris.exe -lgdi32 -luser32参数说明-stdc99 让 gcc 按 C99 标准编译避免源码里用了 // 注释或 for 循环内声明变量时报错-Wall 打开所有常见警告第一次编译时看到警告不要无视特别是“未初始化变量”这类。如果源码里入口函数是 wWinMain需要额外加 -municode否则链接器找不到入口点。如果只有一个 tetris.c命令简化为gcc tetris.c -o tetris.exe -mwindows -lgdi32 -luser32编译成功后当前目录会出现 tetris.exe直接输入 tetris.exe 就能启动。如果窗口一闪而过检查源码里 ShowWindow 之后有没有进入 GetMessage 循环写错了 return 0 会让窗口建完立刻退出。4.3 用 Visual Studio新建空项目并添加源文件Visual Studio 用户最省心但有个“新建项目”的陷阱选错项目类型会导致入口函数编译不过。正确做法是创建“Windows 桌面应用程序”或者“空项目”手动把 tetris.c 拖进 Source Files然后项目属性里找到“链接器-输入”确认依赖库 user32.lib 和 gdi32.lib 已被包含。大多数情况下 VS 会自动处理。推荐把解决方案平台改成 x86而不是 x64。原因不是 x64 不能跑而是很多老代码里使用了 int 强转句柄的做法在 x64 下指针是 64 位int 是 32 位编译会到处警告甚至会截断句柄。游戏本身数据量很小x86 完全够用。编译后没有报错按 F5 就能看到窗口。如果按 F5 后弹出一个终端黑色窗口说明你选了“控制台应用程序”把入口当成了 main。解决方法是把项目属性里的子系统改成“Windows”。4.4 调试技巧日志和断点各看什么这个项目的最佳调试位置有三个消息循环、WM_KEYDOWN、消行函数。第一次跑通后建议在 WM_KEYDOWN 里打一个断点按下方向键观察 wParam 是不是预期的 VK_LEFT。如果断点没触发说明键盘消息被别的控件吃掉了或者窗口没有焦点。另一个常用技巧是在 moveDown() 和 clearLines() 入口加 printf重定向到控制台把方块坐标和消行数打印出来。这个游戏没有复杂多线程日志方式完全够用不需要引入调试器工具。4.5 运行异常窗口尺寸不对或画面偏移很多人编译成功但画面显示不完整比如底部看不到或者左边少了一列。这时候不要急着改绘制代码先查 WndProc 的 WM_SIZE 处理。一个合格版本应该把窗口客户区尺寸和游戏板尺寸绑定或者在创建窗口时直接算好客户区大小。如果源码里用 CreateWindowEx 后没有调整窗口大小客户区可能比预期矮一截。最简单的做法是注册窗口类时用 CS_HREDRAW | CS_VREDRAW并且在 WM_GETMINMAXINFO 里设置最小尺寸。这样窗口缩放不会把游戏板压变形。如果源码里没有这段修改窗口初始宽高参数是最快的办法但要注意宽高指的是整个窗口而不是客户区实测上下边框会占掉约 39 像素。4.6 从命令行参数控制窗口尺寸很多版本的窗口固定是 360x520如果屏幕分辨率不高或者你想边玩边开一个调试日志就希望窗口小一点。可以借用 lpCmdLine 参数让窗口尺寸可变int width 360, height 520; if (lpCmdLine lpCmdLine[0]) { sscanf(lpCmdLine, %d %d, width, height); } CreateWindowEx(0, TetrisWin, C 语言俄罗斯方块, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, width, height, NULL, NULL, hInstance, NULL);lpCmdLine 是命令行字符串比如 tetris.exe 320 480。sscanf 解析失败时保持默认值。注意客户区尺寸比窗口尺寸小绘制函数里的常量 CELL_PIX 和 BOARD_COLS 不会跟着窗口尺寸变所以这个技巧只适合调整外框不要指望游戏画面自动缩放。真要自适应需要在第一步生成位图时按当前窗口客户区重新计算格子像素。这个改法也适合扩展 -speed、-debug 等启动参数解析逻辑放在 WinMain 入口处不侵入游戏逻辑。5. 避坑记录黑屏闪烁、按键失灵和变量未初始化的 5 个现场5.1 画面一闪一闪眼睛都快闪没了现象方块下落时整个窗口不停闪烁尤其格子多的时候特别明显。原因WM_PAINT 里直接往窗口 DC 画矩形系统先擦除背景再触发绘制每一次下落都经历一次“全擦全画”刷新率高时自然闪。这也是没有用双缓冲的典型表现。解决建立内存 DC先画好后 BitBlt 一次复制到窗口。核心代码如下HDC memDC CreateCompatibleDC(hdc); HBITMAP bmp CreateCompatibleBitmap(hdc, BOARD_COLS * CELL_PIX, BOARD_ROWS * CELL_PIX); HGDIOBJ oldBmp SelectObject(memDC, bmp); drawBoard(memDC, board); BitBlt(hdc, 0, 0, WIDTH, HEIGHT, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC);CreateCompatibleBitmap 创建和窗口格式一致的位图BitBlt 用 SRCCOPY 整体复制整个画面只刷新一次闪烁立刻消失。注意要在每个 WM_PAINT 里创建内存 DC用完后删除避免 GDI 对象泄漏。5.2 按一下左键方块连续移动两格现象每次按方向键方块跳两格或更多感觉按键被重复识别。原因键盘消息重复机制。按住方向键时 Windows 会产生多条 WM_KEYDOWN或者代码里同时处理了 WM_CHAR 和 WM_KEYDOWN造成一次物理按键触发多次移动。解决只保留 WM_KEYDOWN删除 WM_CHAR 处理。如果不想让长按连续触发可以判断这次消息是不是按住后重复产生的case WM_KEYDOWN: if (lParam (1 30)) { break; // 上一条按键状态为“已按下”说明是长按重复 } switch (wParam) { // 移动和旋转 } break;lParam 的第 30 位表示“上一按键状态”为 1 时说明这条消息来自按住自动重复刚按下时是 0。对俄罗斯方块来说重复触发会让下落很难控制加上这个判断手感会正常很多。5.3 窗口最小化再恢复方块全变成黑色背景现象把窗口最小化或遮挡后恢复方块消失只剩一个窗口轮廓。原因游戏数据只在按键时触发一次重绘窗口恢复时系统发送 WM_PAINT但如果绘制函数读取的是局部变量或临时数据数据早已消失。通常是把“当前方块”和“游戏板”设计成了函数内局部变量导致每次重绘都是默认空数据。解决把游戏板、当前方块、分数全部定义为全局或窗口结构体只在 WM_PAINT 里读取。推荐做法是定义一个 GameContext 结构体在 WM_CREATE 里初始化在 WM_TIMER 和 WM_KEYDOWN 里修改在 WM_PAINT 里只读。这样窗口恢复后画面依然完整。5.4 编译报错undefined reference toWinMain16现象gcc 编译时最后一行报 undefined reference to WinMain16找不到入口。原因源码入口是 wWinMain 而你用了普通 gcc 命令链接器默认找 WinMain或者源码因为字符集问题被改成了 _tWinMain。Windows 程序在控制台子系统和窗口子系统下需要不同的入口符号。解决入口写成 WinMain编译参数保持匹配。如果源码里已经是 WinMain检查是不是文件被保存成了 GBK 编码且包含 BOM某些编译器在遇到首行乱码时会解析失败。如果入口是 wWinMain记得加 -municode。最稳妥的一招是把入口相关写法固定为int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)5.5 窗口能打开但没有任何反应鼠标也卡住现象窗口正常显示但点按键没反应拖拽窗口时标题栏一直转圈像死机一样。原因在消息循环或 WndProc 里使用了阻塞操作比如 Sleep(500) 放在 moveDown() 里或者在 WM_PAINT 里加载大文件、循环读取硬盘。这样消息循环被长时间占住系统无法派发 WM_PAINT、WM_MOVE窗口自然“假死”。解决回退到定时器方案。所有延迟逻辑都靠 WM_TIMER 驱动哪怕要做暂停也要通过修改状态而不是 Sleep。如果发现某段代码慢用 GetTickCount 记录时间差来替代阻塞等待不要在消息循环里做耗时操作。6. 二次开发给游戏加上等级加速和方块预览如果这套源码已经能跑通下一步最值得改的是“等级加速”。现在的定时器间隔固定为 500 毫秒游戏越玩越快这个核心体验没体现。改造思路是把 SetTimer 的间隔从固定值改成由分数和消行数动态计算的值。int getLevel(int lines) { return lines / 10; } UINT getInterval(int level) { UINT interval 500 - level * 40; if (interval 100) interval 100; return interval; }调用时在每次 clearLines 后重新设置定时器int cleared clearLines(board); totalLines cleared; level getLevel(totalLines); SetTimer(hwnd, TIMER_ID, getInterval(level), NULL);getInterval 把速度下限卡在 100 毫秒避免方块快到人眼跟不上。这种计算方式不依赖 Sleep也不会破坏消息循环是标准的定时器动态调整方案。验证方法很简单先把 clearLines 的返回值打印到日志里手动填满一行看有没有调用 SetTimer然后对比等级变高后下落速度是否明显加快。第二个值得加的是“下一个方块预览”。在全局定义一个 nextShape每次生成当前方块时把 nextShape 赋给当前方块再从随机表里取一个新的 nextShape在窗口右侧画一个 4x4 的小矩阵。这个改动不涉及主循环逻辑只加一次绘制和一次赋值非常适合练手。从那以后我每次拆这种 C 语言小游戏都会先画一张“状态-消息-绘制”的关系图再动手改代码。这个小习惯帮我避开过不少改一发而动全身的坑。希望帮到你。本文还有配套的精品资源点击获取