简介:这是一份基于 Windows API、使用 C 语言编写的俄罗斯方块完整工程,面向刚接触 Windows 编程、希望借助小型项目巩固 C 语言基础与窗口消息机制的初学者。源码带有较完整注释,涵盖游戏主循环、方块绘制、键盘响应和状态切换等环节,可对照理解左右键控制方向、下键加速、上键变形、空格暂停等操作背后的实现方式,也便于熟悉 VC++6.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.c | WinMain、窗口类注册、消息循环、WndProc | 120 |
| game.c | 棋盘状态、方块生成、碰撞、消行、计分 | 300 |
| render.c | 双缓冲、格子绘制、面板文字 | 220 |
| input.c | 按键到动作的映射 | 80 |
| tetromino.h | 常量、结构体、对外函数声明 | 60 |
单文件也能塞下七八百行,但改起来痛苦:改个配色要往下滚两百行,调个下落速度又要在渲染代码里翻。拆开之后规则层不 include 任何 windows.h,game.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 | 高光提亮 |
|---|---|---|---|
| 0 | I | (41, 197, 246) | 45% |
| 1 | O | (247, 208, 56) | 45% |
| 2 | T | (168, 96, 234) | 45% |
| 3 | S | (86, 200, 110) | 45% |
| 4 | Z | (232, 78, 78) | 45% |
| 5 | J | (66, 118, 232) | 45% |
| 6 | L | (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 里存成一个全局 HFONT,WM_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 行为或者一行写错的字节数,按“现象 → 原因 → 解决”记下来,遇到同类
本文还有配套的精品资源,点击获取