ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言俄罗斯方块源码:Win32 GDI消息泵与核心算法解析

C语言俄罗斯方块源码:Win32 GDI消息泵与核心算法解析

简介:这是一份用C语言结合Windows API编写的俄罗斯方块游戏源码,面向刚接触Windows编程、希望把C语言基础落到实战的初学者,也适合想了解经典小游戏完整实现流程的开发者参考。整个项目在VC++6.0环境下开发,压缩包共28个文件、约1.12MB,其中cpp与h源文件承载游戏主逻辑与函数划分,rc资源脚本与ico图标负责界面与图形资源,dsp、dsw工程文件保留了完整的编译配置,另附可直接运行的exe程序与ReadMe说明,覆盖从工程搭建到资源管理的常见环节。代码中保留了较完整的注释,读者可以顺着窗口创建、消息循环、方块绘制与下落判定等线索,理解Windows API的实际调用方式;键盘响应部分对应左右键移动、下键加速、上键变形、空格暂停,逻辑边界清晰,便于拆解后改写或扩展。目前已有4515人学习下载,适合作为练手项目对照阅读与二次开发。

1. 从控制台跳到窗口:这份 C 语言俄罗斯方块源码到底给了什么

很多人写的第一版俄罗斯方块,是在控制台里用printf摆字符:屏幕不停闪、每次按键都要刷新整屏、想画出下一块预告得自己排版。换成 Windows 编程之后,界面从字符格变成真正的图形窗口,键盘按下立即响应,方块下落不阻塞主线程——代价是得自己管消息循环、设备上下文和定时器。这份 C 语言俄罗斯方块源码走的就是后一条路:纯 C、不依赖 C++ 类和第三方图形库,窗口、键盘、绘制全部由 user32 和 gdi32 两个系统 DLL 撑起来。它适合两类人:刚啃完 C 语法、想找一个能从第一行读到最后一行的图形程序练手的;以及想拿一副干净骨架去改贪吃蛇、扫雷、推箱子的。源码里没有花哨特效,但棋盘、方块、碰撞、消行、计分、等级加速这几块都在,逻辑边界清楚,改起来不心虚。

2. 工程骨架拆解:Win32 消息泵加定时器撑起的游戏循环

2.1 为什么选纯 C 加 Win32 GDI,而不是图形库

控制台版和 Win32 版的差别,本质是「谁在等谁」。控制台版的输入是阻塞的,程序停在_getch()上,游戏时间完全由玩家按键驱动,你没法让方块自己往下掉:

/* 控制台版的典型写法:整个循环卡在输入上 */ while (running) { int ch = _getch(); /* 不按键就永远不返回 */ if (ch == 0 || ch == 224) ch = _getch(); /* 方向键是两个字节 */ move_by_key(ch); system("cls"); /* 全屏清屏,闪屏的根源 */ draw_all(); }

Win32 版反过来,输入、计时、绘制都是「事件」,谁来了处理谁,互不阻塞:

case WM_KEYDOWN: Game_KeyDown((int)wParam); InvalidateRect(hwnd, NULL, FALSE); break; case WM_TIMER: Game_Tick(); InvalidateRect(hwnd, NULL, FALSE); break; case WM_PAINT: OnPaint(hwnd); break;

选纯 C 加 Win32,好处是零依赖:拿到.c文件,装个 MinGW 或者开个 MSVC 命令行就能编出 exe,不用配 CMake、不用下 SDL 的 dll、不用管运行时库版本。坏处也明摆着:GDI 是 CPU 绘制,像素级动画撑不住,但 10×20 的格子加几十个矩形,一帧几百次FillRect,对现代机器来说完全无压力。真要往上做到 60fps 粒子特效,那就该换 Direct2D 或者别的渲染后端了,只是那就不是这份源码想解决的问题。

我一般会把这条界限讲给来问的人:Win32 + GDI 适合「格子类、回合感强、每帧元素不超过几百个」的游戏;一旦需要逐像素混色、半透明叠加、大量精灵,就尽早换渲染方案,别在 GDI 上硬扛。

2.2 目录结构与各模块职责

源码包常见的拆法是按「入口 / 规则 / 绘制 / 输入」四块分开,逻辑上不交叉,方便单独替换其中一层:

文件职责关键导出函数
main.cWinMain、注册窗口类、创建窗口、消息泵WinMain、WndProc
game.h常量、结构体、跨模块函数声明宏COLSROWSCELL
game.c棋盘数组、方块生成、下落、碰撞、消行、计分Game_InitGame_TickGame_Move
render.c双缓冲、画网格、画固定块、画当前块、画预览Render_Frame
input.c键码到动作的映射、长按连发(DAS/ARR)Input_KeyDownInput_Poll

也有不少源码包就是一个tetris.c从头写到尾,两三千行,其实拆开看还是这四段。读的时候按这四段切,比按行号顺序读要快得多。这里我建议一个读法:先找WndProc,把WM_CREATE、WM_TIMER、WM_KEYDOWN、WM_PAINT四个分支读透,整个程序的时序就清楚了,剩下的都是被这四个分支调用的纯函数。

2.3 主循环:消息泵负责「醒」,定时器负责「走」

Win32 程序没有传统意义上的while(game_running),游戏的时间推进靠SetTimer。WinMain里最关键的三段是注册窗口类、按客户区反推窗口尺寸、进消息泵:

int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR cmd, int show) { /* 1. 注册窗口类:指定消息处理函数和背景色 */ WNDCLASSA wc = {0}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); wc.lpszClassName = "TetrisWnd"; RegisterClassA(&wc); /* 2. 客户区固定 10*24 x 20*24,再让系统把边框标题栏算进去 */ RECT rc = {0, 0, COLS * CELL, ROWS * CELL}; AdjustWindowRect(&rc, WS_OVERLAPPEDWINDOW, FALSE); HWND hwnd = CreateWindowA("TetrisWnd", "Tetris", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, NULL, NULL, hInst, NULL); ShowWindow(hwnd, show); /* 3. 消息泵:没消息就阻塞在这里,不烧 CPU */ MSG msg; while (GetMessage(&msg, NULL, 0, 0) > 0) { TranslateMessage(&msg); /* 把 WM_KEYDOWN 翻成 WM_CHAR */ DispatchMessage(&msg); /* 派发给 WndProc */ } return (int)msg.wParam; }

AdjustWindowRect这一步不能省。省了以后客户区会比COLS*CELL小一圈,右边和下边的格子被裁掉,画出来就是「棋盘少了半格」,查半天还以为棋盘数组写错了。参数FALSE表示没有菜单栏,如果给窗口加了菜单,要传TRUE。

定时器是游戏的节拍器,间隔决定当前等级的下落速度:

#define ID_TIMER_TICK 1 #define TICK_BASE_MS 500 /* 1 级:每 500ms 落一格 */ case WM_CREATE: SetTimer(hwnd, ID_TIMER_TICK, TICK_BASE_MS, NULL); return 0; case WM_TIMER: if (wParam == ID_TIMER_TICK && g_state == STATE_PLAYING) { Game_Tick(); /* 走一步逻辑 */ InvalidateRect(hwnd, NULL, FALSE); /* 标脏,等系统发 WM_PAINT */ } return 0;

InvalidateRect第三个参数传FALSE,意思是「别先擦背景」。因为WM_PAINT里我们会整屏重画,多擦一次只会闪一下。SetTimer的精度受系统时钟片影响,一般在 15ms 上下浮动,别拿它做毫秒级动画,但作为方块下落节拍绰绰有余。

难度提升就是换间隔:

static UINT tick_interval_for_level(int level) { int ms = TICK_BASE_MS - (level - 1) * 40; /* 每级快 40ms */ return (UINT)(ms < 100 ? 100 : ms); /* 封底 100ms */ }

升级时先KillTimer,再SetTimer传新间隔。不KillTimer直接再调一次SetTimer同 ID,系统会替换掉原定时器,行为其实一样,但显式写两步更好读。

3. 方块模型与核心算法:四种状态的形状表加碰撞检测

3.1 数据结构:棋盘数组、当前块、下一块

棋盘是一个二维整型数组,值为 0 表示空,1~7 表示该格被哪种方块占住。存方块类型而不是只存 1,是为了让固定块也能按类型上色:

#define COLS 10 #define ROWS 20 #define CELL 24 /* 每格 24 像素 */ int board[ROWS][COLS]; /* 0 = 空,1~7 = 已固定的方块类型 */ typedef struct { int type; /* 0..6 对应 I O T S Z J L */ int rot; /* 0..3 四种旋转态 */ int x, y; /* 锚点在棋盘坐标系里的位置 */ } Tetromino;

注意数组声明顺序是board[y][x],不是board[x][y]。这一点在碰撞检测里最容易翻车:写反了以后越界判断会变成「上下出界」,方块从屏幕左右走出去却不报错。

方块形状不存成矩阵,而是把每种方块四种旋转态的四个格子偏移全部写死成一张常量表:

/* SHAPES[type][rot][i] = {dx, dy},i 是四格中的第 i 格 */ static const int SHAPES[7][4][4][2] = { /* I */ { {{0,1},{1,1},{2,1},{3,1}}, {{2,0},{2,1},{2,2},{2,3}}, {{0,2},{1,2},{2,2},{3,2}}, {{1,0},{1,1},{1,2},{1,3}} }, /* O */ { {{1,0},{2,0},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{2,1}} }, /* T */ { {{1,0},{0,1},{1,1},{2,1}}, {{1,0},{1,1},{2,1},{1,2}}, {{0,1},{1,1},{2,1},{1,2}}, {{1,0},{0,1},{1,1},{1,2}} }, /* S */ { {{1,0},{2,0},{0,1},{1,1}}, {{1,0},{1,1},{2,1},{2,2}}, {{1,1},{2,1},{0,2},{1,2}}, {{0,0},{0,1},{1,1},{1,2}} }, /* Z */ { {{0,0},{1,0},{1,1},{2,1}}, {{2,0},{1,1},{2,1},{1,2}}, {{0,1},{1,1},{1,2},{2,2}}, {{1,0},{0,1},{1,1},{0,2}} }, /* J */ { {{0,0},{0,1},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{1,2}}, {{0,1},{1,1},{2,1},{2,2}}, {{1,0},{1,1},{0,2},{1,2}} }, /* L */ { {{2,0},{0,1},{1,1},{2,1}}, {{1,0},{1,1},{1,2},{2,2}}, {{0,1},{1,1},{2,1},{0,2}}, {{0,0},{1,0},{1,1},{1,2}} }, };

写死比「用矩阵乘法实时算旋转」多花一点存储,但换来三个好处:没有浮点、没有cos/sin、调试时能一行一行对着图看。表格里每个旋转态的四个偏移都是相对于锚点(x, y)的,锚点本身不动,只换偏移,方块看起来就在原地转。

3.2 旋转与简易踢墙

旋转本身只是一行rot = (rot + dir + 4) % 4,坑在于紧贴墙壁或贴着已有方块时,转过去会重叠。真实商业俄罗斯方块的踢墙规则是一整套表,这里的做法是准备几个尝试顺序,撞了就换位置:

int Tetromino_Rotate(Tetromino *t, int dir) { int old_rot = t->rot; t->rot = (t->rot + dir + 4) % 4; /* 依次尝试:原地、左一格、右一格、左两格、右两格、上抬一格 */ static const int KICK[6][2] = {{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1}}; for (int i = 0; i < 6; i++) { Tetromino trial = *t; trial.x += KICK[i][0]; trial.y += KICK[i][1]; if (!Board_Collide(&trial)) { *t = trial; /* 找到能放下就采纳 */ return 1; } } t->rot = old_rot; /* 全部失败,回滚旋转态 */ return 0; }

KICK的顺序有讲究:{0,0}放第一个,因为绝大多数旋转本来就能原地转;左右两格排在后面,只在贴墙时命中。{0,-1}上抬一格放在最后,是给 I 型块在地面附近翻横竖留一条活路。trial必须是副本,直接改*t再判碰撞,失败时回滚就会把坐标也一起脏掉。

返回0表示这次旋转被完全否决,调用方不需要做别的处理,界面不会变,玩家会觉得「卡住了」,这正是预期行为。

3.3 碰撞检测与消行

碰撞检测是整套逻辑里被调用最频繁的函数,一次下落、一次移动、一次旋转都跑一遍,所以写得越短越好:

int Board_Collide(const Tetromino *t) { for (int i = 0; i < 4; i++) { int cx = t->x + SHAPES[t->type][t->rot][i][0]; int cy = t->y + SHAPES[t->type][t->rot][i][1]; if (cx < 0 || cx >= COLS) return 1; /* 左右出界 */ if (cy >= ROWS) return 1; /* 触底 */ if (cy >= 0 && board[cy][cx]) return 1; /* 撞到已固定的块 */ } return 0; }

cy < 0被允许,因为方块刚从顶部生成时有一格可能在屏幕上方之外,属于正常状态,不需要判死。但cy >= 0那一层判断必须有,否则数组负下标读越界,在 Windows 上通常不是崩,而是读出随机值,表现成「方块有时能穿模、有时被卡」,极难复现。

下落一步就是「试走 + 回滚」:

void Game_Tick(void) { Tetromino t = g_cur; t.y++; if (Board_Collide(&t)) { Lock_Piece(); /* 落不动了,固化进棋盘 */ Clear_Lines(); /* 检查消行 */ Spawn_Next(); /* 出新块,出了就撞则游戏结束 */ } else { g_cur = t; } }

消行的写法是自下而上扫,遇到整行就把上面所有行整体下移一格,然后同一行再判一次,这样连消四行能一次清完:

int Clear_Lines(void) { int cleared = 0; for (int y = ROWS - 1; y >= 0; y--) { int full = 1; for (int x = 0; x < COLS; x++) { if (!board[y][x]) { full = 0; break; } } if (!full) continue; for (int yy = y; yy > 0; yy--) memcpy(board[yy], board[yy - 1], sizeof(board[0])); memset(board[0], 0, sizeof(board[0])); cleared++; y++; /* 关键:下一轮还判这一行 */ } return cleared; }

memcpy那一行是整个函数里最容易写错的地方:sizeof(board[0])是「一行有多少字节」,不是「数组多大」。写成sizeof(board)就会把整个棋盘覆盖到一行里去,编译不报错,运行时直接踩内存,表现成花屏或者秒退。

生成新块时用 7-bag 随机,避免连续来七个 S 型那种玄学:

static int bag[7], bag_pos = 7; static int Next_Type(void) { if (bag_pos >= 7) { for (int i = 0; i < 7; i++) bag[i] = i; for (int i = 6; i > 0; i--) { /* Fisher-Yates 洗牌 */ int j = rand() % (i + 1); int tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp; } bag_pos = 0; } return bag[bag_pos++]; }

一袋七块,每块刚好出现一次,玩家拿到的序列里最长空缺是十二块,比纯rand() % 7的手感稳得多。

4. 渲染与输入:双缓冲 GDI 绘制和不靠系统连发的键盘响应

4.1 双缓冲:所有绘制先落内存位图

单缓冲直接往窗口 DC 上画,WM_PAINT一触发,系统先擦背景再等你画,中间有个空档,肉眼看到的就是闪。双缓冲的思路很直接:先在内存位图上画完整的帧,再一次性拷贝到屏幕:

static void OnPaint(HWND hwnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); /* 1. 建一张与窗口兼容的内存位图 */ HDC mem = CreateCompatibleDC(hdc); HBITMAP bmp = CreateCompatibleBitmap(hdc, COLS * CELL + PANEL_W, ROWS * CELL); HBITMAP old = (HBITMAP)SelectObject(mem, bmp); /* 2. 全部画在内存里 */ Render_Background(mem); Render_Board(mem); Render_Current(mem); Render_Next(mem); Render_Score(mem); /* 3. 一次性上屏 */ BitBlt(hdc, 0, 0, COLS * CELL + PANEL_W, ROWS * CELL, mem, 0, 0, SRCCOPY); /* 4. 还原并释放,否则每秒泄露一次 GDI 对象 */ SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem); EndPaint(hwnd, &ps); }

实际工程里,CreateCompatibleDC和CreateCompatibleBitmap一般放在WM_CREATE里建一次,缓存到全局,WM_SIZE时重建,WM_DESTROY时销毁。每次WM_PAINT现建现删虽然写起来简单,但每秒几十次创建位图,跑久了句柄数会接近系统的 GDI 对象上限(默认一万个),表现成界面突然不刷新。这是血泪经验:一旦切出去再切回来画面卡死,先查 GDI 对象有没有泄漏。

4.2 画格子和上色

棋盘和方块的绘制都是同一个套路:把格子坐标换算成像素矩形,FillRect填色,再用稍亮的颜色描一道内边框做出立体感:

static const COLORREF PIECE_COLOR[8] = { RGB(0,0,0), /* 0 空,不用 */ RGB(0,240,240), /* 1 I 青 */ RGB(240,240,0), /* 2 O 黄 */ RGB(160,0,240), /* 3 T 紫 */ RGB(0,240,0), /* 4 S 绿 */ RGB(240,0,0), /* 5 Z 红 */ RGB(0,0,240), /* 6 J 蓝 */ RGB(240,160,0), /* 7 L 橙 */ }; static void Draw_Cell(HDC hdc, int cx, int cy, int type) { RECT r = { cx * CELL, cy * CELL, (cx + 1) * CELL, (cy + 1) * CELL }; HBRUSH br = CreateSolidBrush(PIECE_COLOR[type]); FillRect(hdc, &r, br); DeleteObject(br); /* 一定要删,CreateSolidBrush 是稀缺资源 */ /* 顶边和左边提亮,做出高光 */ HPEN pen = CreatePen(PS_SOLID, 1, RGB(255,255,255)); HPEN old = (HPEN)SelectObject(hdc, pen); MoveToEx(hdc, r.left, r.top, NULL); LineTo(hdc, r.right - 1, r.top); LineTo(hdc, r.right - 1, r.bottom - 1); SelectObject(hdc, old); DeleteObject(pen); }

CreateSolidBrush、CreatePen都是 GDI 对象,和文件句柄一样有配额。在绘制函数里现建现删没问题,但必须成对。见过很多改崩的版本,就是加了一行CreateSolidBrush忘了DeleteObject,跑两三分钟以后窗口彻底变黑,任务管理器里 GDI 对象数一路涨到九千多。

小块绘制做完,棋盘和当前块分别循环一遍:

void Render_Board(HDC hdc) { for (int y = 0; y < ROWS; y++) for (int x = 0; x < COLS; x++) if (board[y][x]) Draw_Cell(hdc, x, y, board[y][x]); } void Render_Current(HDC hdc) { for (int i = 0; i < 4; i++) { int cx = g_cur.x + SHAPES[g_cur.type][g_cur.rot][i][0]; int cy = g_cur.y + SHAPES[g_cur.type][g_cur.rot][i][1]; if (cy >= 0) /* 顶部外的格子不画 */ Draw_Cell(hdc, cx, cy, g_cur.type + 1); } }

type + 1是因为PIECE_COLOR[0]被空位占了,方块类型 0~6 要映射到颜色 1~7。这种「差一」的映射在源码里很常见,改颜色表的时候记得两头一起改。

4.3 键盘:自己实现长按连发,别依赖系统

WM_KEYDOWN有个特性:按住不放,系统会以「连发延迟 + 连发频率」持续发送。但这套参数是操作系统的键盘设置,玩家调过之后手感完全不同,而且初始延迟往往长达 500ms,玩起来像按键失灵。正确做法是只把第一次WM_KEYDOWN当作「按下」,连发自己算:

#define DAS_MS 150 /* 按下后多久开始连发 */ #define ARR_MS 40 /* 连发间隔 */ typedef struct { int held; UINT next_ms; } KeyRepeat; static KeyRepeat g_kr[2]; /* 0 = 左,1 = 右 */ void Input_KeyDown(int dir, UINT now) { Game_Move(dir, 0); /* 首次立刻响应,不等待 */ int i = (dir > 0); g_kr[i].held = 1; g_kr[i].next_ms = now + DAS_MS; /* 然后进入延迟期 */ } void Input_Poll(UINT now) { for (int i = 0; i < 2; i++) { if (!g_kr[i].held || now < g_kr[i].next_ms) continue; Game_Move(i ? 1 : -1, 0); g_kr[i].next_ms = now + ARR_MS; /* 之后按 ARR 节奏走 */ } } void Input_KeyUp(int dir) { g_kr[dir > 0].held = 0; }

DAS(Delayed Auto Shift)和ARR(Auto Repeat Rate)这两个词,是竞速类方块玩家最在意的参数,150/40 是比较通用的舒适值。要做得更细,可以给下键单独一套软降参数,再配一个「落地锁定延迟」,让方块触底后还有几百毫秒能左右微调——没有锁定延迟的版本,方块一碰底就固定,玩起来会非常暴躁。

Input_Poll什么时候调?挂到WM_TIMER里,和Game_Tick一起跑。用同一个定时器最简单,缺点是连发精度受下落间隔影响;讲究一点就再开一个 16ms 的高频定时器专门跑输入。

5. 编译运行与避坑:从源码到能玩的 exe 这一路

5.1 三种编译方式,命令怎么写

MinGW-w64 一行搞定,-mwindows表示 GUI 子系统,双击运行不弹黑框:

gcc main.c game.c render.c input.c -o tetris.exe -mwindows -lgdi32 -luser32 -O2

MSVC 开发者命令行:

cl /nologo /O2 /W3 main.c game.c render.c input.c ^ /link user32.lib gdi32.lib /SUBSYSTEM:WINDOWS

Visual Studio 里建空项目,把.c文件加进去,配置属性里把「链接器 → 系统 → 子系统」改成「窗口」,再在「链接器 → 输入 → 附加依赖项」补user32.lib;gdi32.lib;,也能过。

一个提醒:-lgdi32和-luser32是链接器参数,必须放在所有.c文件后面。放到前面 GCC 会报一堆undefined reference to TextOutA之类的错,其实代码一行没错,纯粹是顺序问题。

5.2 五条实打实的踩坑记录

坑一:编译过了,双击闪一下就没了

现象:编译零警告,命令行里运行能看到一闪而过的黑框或者干脆没反应,exe 消失。

原因是入口函数对不上。Win32 GUI 程序的入口必须是WinMain,源码如果写的是int main(int argc, char** argv),配-mwindows链接时会找不到入口,某些工具链会静默链接到一个空壳。解决:确认入口是WinMain;如果源码本来就是main,把-mwindows去掉,或者改成WinMain里调main。

坑二:窗口出来了,但整片黑,只有拖动窗口才偶尔画出一点东西

现象:程序启动后客户区全黑,标题栏正常,用鼠标拖一下窗口边框,方块会突然出现然后又消失。

原因是只处理了WM_PAINT却没有正确画,或者WM_TIMER里根本没调InvalidateRect。Win32 不会自动重绘,逻辑改了必须主动标脏。另一种可能是BeginPaint/EndPaint之外用了GetDC画图,画的都在下一次擦背景时被清掉。解决:所有绘制统一收进WM_PAINT,逻辑侧只调InvalidateRect,别在定时器里直接GetDC。

坑三:玩着玩着窗口不刷新了,切出去再切回来才恢复

现象:连续玩两三分钟,画面冻住,方块不动,但键盘还有反应(听不到声音无从判断时,表现为按键后画面完全不更新)。切到别的窗口再切回来,恢复正常,再过一会儿又冻。

原因是 GDI 对象泄漏。每次WM_PAINT都CreateCompatibleBitmap、CreateSolidBrush、CreatePen而没有对应的DeleteObject,进程的 GDI 句柄数涨到系统上限(默认 10000)后,所有创建调用静默返回NULL,画不出来了。解决:观察任务管理器「详细信息」标签里的「GDI 对象」列,跑一分钟看数字是不是持续上涨;涨就逐个函数查配对的DeleteObject。

坑四:方向键按一下没反应,或者一按就冲出去好几格

现象:轻点左右方向键,方块经常完全不动;按住又感觉速度完全不受控,像是跟着系统键盘设置走。

原因就是直接用了WM_KEYDOWN的连发。系统的连发参数在控制面板里可调,不同机器上手感完全不同,而且初始延迟默认在 500ms 左右。解决:按 4.3 的做法自己实现 DAS/ARR;WM_KEYDOWN里判断lParam的第 30 位(上一次按键状态),值为 1 说明这是连发消息,直接丢弃:

case WM_KEYDOWN: if (lParam & 0x40000000) return 0; /* bit30 = 1,是系统连发,忽略 */ Input_KeyDown(map_key(wParam), GetTickCount()); return 0;

坑五:用 VS 打开,报一大堆语法错误,同一份代码 GCC 完全正常

现象:error C2059: 语法错误: "{"、error C2065: "for": 未声明的标识符这类,数量几十条。

原因是代码用了 C99 特性:在for里声明循环变量(for (int i = 0; ...))、复合字面量、指定初始化器。MSVC 老版本按 C89 编译.c文件,这些全不认。解决:项目属性里「C/C++ → 语言 → C 语言标准」选 C11;或者把代码改成int i;提前声明。这条特别容易在新手身上触发,因为同一份代码在别处跑得好好的。

6. 二次开发:从「能玩」到「像样」的改造路线

源码跑起来之后,最自然的下一步是把它改得像一个「现代」一点的方块游戏。我按改动成本和收益排了一个顺序,从最便宜的开始:

改造点改动位置成本手感收益
幽灵方块(落点预览)render.c新增函数低极高
硬降(空格瞬落)input.c+game.c低高
Hold 暂存块game.c状态中高
落地锁定延迟game.c的Game_Tick中高
一场一段的本地最高分main.c读写文件中中

幽灵方块最值得先加,几十行代码,玩家立刻能判断落点,是从「凑合能玩」到「愿意多玩几把」的分水岭。实现思路是把当前方块从当前位置一路往下探,探到再落一格就撞为止:

void Render_Ghost(HDC hdc) { Tetromino g = g_cur; Tetromino probe = g; /* 一直往下试,试到再下一格就撞 */ for (;;) { probe.y = g.y + 1; if (Board_Collide(&probe)) break; g = probe; } /* 只描边不填充,视觉上和实体块区分开 */ for (int i = 0; i < 4; i++) { int cx = g.x + SHAPES[g.type][g.rot][i][0]; int cy = g.y + SHAPES[g.type][g.rot][i][1]; if (cy < 0) continue; RECT r = { cx * CELL, cy * CELL, (cx + 1) * CELL, (cy + 1) * CELL }; FrameRect(hdc, &r, (HBRUSH)GetStockObject(GRAY_BRUSH)); } }

这里的probe和上面旋转里的trial是同一个套路:改副本、判碰撞、成功才采纳。别图省事直接g_cur.y++试,试失败以后原来的位置就找不回来了,玩家会看到方块莫名其妙跳一截。

调用顺序要在Render_Current之前,否则幽灵块会盖在实体块上,看起来像方块变灰了。

硬降更能立竿见影:

void Game_HardDrop(void) { int dist = 0; Tetromino t = g_cur; while (1) { Tetromino probe = t; probe.y++; if (Board_Collide(&probe)) break; t = probe; dist++; } g_cur = t; g_score += dist * 2; /* 每硬降一格给 2 分,鼓励玩家主动操作 */ Lock_Piece(); Clear_Lines(); Spawn_Next(); }

dist * 2这种计分设计不是随便写的:硬降给分,玩家才会主动按下去跳过等待,游戏节奏才快得起来;不给分的话,绝大多数人会老老实实等方块自然落下。

还有一个容易被忽略的点:改完一定要回放一遍边界情况。具体是三个动作——在最左边贴着墙旋转 I 型块、在最下面那行连消四行、在方块刚生成就撞顶时看游戏是否正常结束。我第一次改旋转踢墙表的时候,就是漏了贴左墙的情况,转一次直接数组越界,程序假死,调试了快一小时才发现是x变成 -1 之后board[cy][cx]读到了相邻内存。从那以后我每次动旋转或碰撞相关的代码,都强制把这三个边界跑一遍再提交,比任何单元测试都省事。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进