ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C语言Win32俄罗斯方块实战:GDI绘图与游戏逻辑详解

C语言Win32俄罗斯方块实战:GDI绘图与游戏逻辑详解

简介:这是一份面向C语言初学者与Windows编程入门者的实战项目源码,用Windows API在VC++6.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, L"Tetris", 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); #endif

OutputDebugStringA的输出在 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 nextPiece,SpawnPiece里把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只做分发,一行都不多写。这个习惯帮我省了太多回头改代码的时间。希望这份拆解能帮你少走几步弯路,把这块会转的方块改成你自己的样子。

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

返回列表