
如果你翻过 Windows 编程的入门资料十有八九会被CreateWindow、WNDCLASS、GetMessage这一串英文词吓住。我当年第一次接触 win32API 时也一样啃了几天文档代码抄下来能跑但稍微改动一个参数就崩得莫名其妙。后来把项目换成贪吃蛇才真正把这些概念串起来。贪吃蛇这个项目看着简单但麻雀虽小五脏俱全窗口创建、消息循环、键盘事件、定时器、GDI 绘制、碰撞检测、状态管理全都有。它不会像计算器那样只处理几个按钮消息也不会像文本编辑器那样需要处理复杂的输入法逻辑刚好卡在学习曲线最舒服的位置。这篇文章就把我从头实现这个 demo 的过程完整拆开适合刚看完 C 语言基础、想接触 Windows GUI 编程的读者也适合那些被“窗口过程”折磨过的初学者。废话不多说我们直接进入项目本身。1. 为什么选贪吃蛇来啃 win32API很多初学者有个误区觉得学 win32API 就该从“做一个漂亮的界面”开始。恰恰相反界面越漂亮你要处理的消息和自绘逻辑就越复杂最后大量时间花在美工和控件布局上核心机制反而没学到。贪吃蛇这个项目我推荐它的理由很直接它逼着你把 win32API 最重要的几块骨髓都吃进去又不会让你被太多边缘 API 分散注意力。1.1 Win32 API 到底是个什么鬼Win32 API 并不是一种“语言”而是操作系统给应用程序开放的函数接口。你用 C 语言可以直接调用CreateWindow、SetTimer、TextOut这些函数让系统帮你创建窗口、发消息、画文字。它的本质是“你和操作系统之间的对话协议”——你负责告诉系统想要什么系统通过回调函数和消息机制告诉你发生了什么。这里有两个核心点需要先建立认知。第一Win32 API 是过程性的不是面向对象的。虽然现在写窗口程序经常用类封装但 API 本身是扁平的 C 函数。第二它的事件模型是“消息驱动”。系统把鼠标点击、键盘按下、窗口重绘这些事情包装成一条条消息派发给你的窗口过程你的代码在窗口过程里分类处理。理解了这两点后面所有代码都只是给这个骨架填肉。1.2 贪吃蛇这个项目好在哪贪吃蛇的经典规则人尽皆知蛇在封闭区域里移动吃食物变长撞墙或咬到自己则结束。我选择它作为 win32API 入门项目是因为它的复杂度和学习目标刚好一一对应。窗口基础对应WNDCLASS注册、CreateWindow创建消息循环对应GetMessage/DispatchMessage输入处理对应WM_KEYDOWN分支动态行为对应SetTimer和WM_TIMER绘图输出对应WM_PAINT、GDI 矩形和椭圆绘制游戏逻辑对应数据结构和碰撞算法。一个项目练完Win32 编程的几个大块头全部过了一遍后面再做文件管理器、绘图板这类工具时无非是往这个框架里加内容。2. 动手之前必须想清楚的几个基础概念如果你直接抄代码顶多抄个形似。碰到画面闪烁、蛇穿墙、按键失灵你就只能瞎猜。所以在写第一行代码之前我建议你先花几个小时把下面这几个概念彻底搞明白。这是整个项目的脚手架。2.1 窗口、窗口类、窗口过程Win32 里“窗口”这个词比普通理解的范围要广。一个按钮是窗口一个输入框是窗口一个滚动条也是窗口。它们都归属于某个“窗口类”。窗口类定义一个窗口的公共属性背景刷子、图标、光标、窗口过程地址。所以编程时你要先填一个WNDCLASS结构体把它注册到系统里再用这个类创建具体的窗口实例。窗口过程WndProc是每个窗口的核心回调函数。系统每产生一个事件就通过消息队列派发消息最终调用这个回调。注意窗口过程不是你在main里主动调用的是系统在恰当时候回调的。初学者最不习惯的就是这种“被动执行”的模型。2.2 消息循环程序的发动机Win32 程序一旦进入GetMessage循环就会被阻塞住直到有消息到达。收到消息后调用TranslateMessage处理键盘翻译再调用DispatchMessage把消息分发给目标窗口的窗口过程。这样窗口过程始终只处理“当前这一条消息”处理完就返回界面的响应性才有保证。我见过有同学在窗口过程里面写死循环做蛇的移动逻辑结果整个窗口立刻卡死。正确做法是让逻辑由消息驱动定时器到点发WM_TIMER窗口过程更新一下蛇的位置再通知系统重绘。每次干一点活既满足游戏实时性又不会卡界面。2.3 GDI 与设备上下文GDI图形设备接口是 Windows 的 2D 绘制系统。它不直接让你操作显卡而是给你一个“设备上下文”对象。你往 DC 上画线、画矩形、画文字GDI 负责把绘制指令转化成像素或硬件操作。在WM_PAINT里你通过BeginPaint拿到窗口 DC画完再EndPaint。这个 DC 就像是画板你的所有画笔都画在这个画板上。贪吃蛇主要用Rectangle绘制方块、Ellipse绘制食物用FillRect填充背景。如果你想让画面更平滑还得掌握双缓冲先在内存 DC 上画好整幅图再一次BitBlt拷贝到窗口 DC。这个方法在后面几乎必用因为直接在窗口 DC 上逐帧画会导致严重闪烁。2.4 坐标系统与像素单位Win32 窗口的坐标原点在客户区左上角x 轴向右y 轴向下。注意y 轴向下为正和数学坐标系正好相反。贪吃蛇如果按数学坐标习惯去写就会出现上下颠倒的诡异现象。我建议把游戏地图抽象成网格定义格子大小GRID 20地图宽MAP_W 40格、高MAP_H 30格。蛇身坐标存的是格子坐标(gridX, gridY)绘制时才乘以GRID得到像素坐标。这样做的好处是逻辑层和渲染层分离碰撞检测只比较格子坐标绘制时才换算成像素定位问题会轻松很多。3. 游戏逻辑设计先画清楚再写代码我在写任何小游戏前都习惯先在纸上把状态机画出来。贪吃蛇的状态其实非常简单游戏进行中、死亡结束。但它的数据模型反而不像看起来那么轻量因为蛇身是一串动态变化的坐标序列处理起来要比普通数组稍微绕一点。3.1 数据模型地图、蛇、食物我最终采用的方案是三个全局数据结构snake数组、food坐标和方向枚举。#define MAX_LEN 1024 #define GRID 20 #define MAP_W 40 #define MAP_H 30 struct Point { int x; int y; }; Point snake[MAX_LEN]; // 蛇身snake[0] 是蛇头 int snakeLen; enum Dir { UP, DOWN, LEFT, RIGHT }; Dir dir; // 当前实际移动方向 Dir pendingDir; // 玩家按键产生的待生效方向 Point food; // 食物坐标 int score; bool gameOver;数组能存最多 1024 节对贪吃蛇来说绝对够。不用链表的原因很简单我没有头尾插入删除的极端性能要求直接用数组加循环移位就能完成移动代码量少且不容易掉链子。食物坐标用一个Point存格子位置生成时保证不在蛇身上。3.2 移动算法每一步先算再画贪吃蛇的移动不能直接改蛇头的坐标因为蛇身每一节都要跟着前一代走。有一个经典的数组移位算法每次移动时先把整条蛇身向后错一位再设置新蛇头void moveSnake(Point newHead) { for (int i snakeLen - 1; i 0; i--) { snake[i] snake[i - 1]; } snake[0] newHead; }从数组尾部开始循环把当前位置改成前一个位置的值最后蛇头赋值新坐标。这里有个关键点移动和“是否吃到食物”的判断有先后关系。我习惯是先判断新蛇头是否会撞到食物若是则先让snakeLen加 1再执行移动。这样循环范围和蛇身长度都会自动包含新增加的那一节不会出现新产生的尾巴没有画出来的问题。3.3 碰撞判定边界与自身碰撞判定是游戏最核心的一环。撞墙判断非常简单新蛇头的x或y超出[0, MAP_W-1]或[0, MAP_H-1]就算输。吃自己则需要遍历整个蛇身数组看看新蛇头坐标是否和任意一个已有蛇身节点重合。这里有个细节要注意当蛇身较短时如果遍历判断包含蛇尾有可能出现“刚离开的位置被判定为撞上”的误判。因为moveSnake之后尾巴会移动但我是用新蛇头去比“移动前”的蛇身数组所以最好在计算新蛇头、执行移动之前完成遍历。如果新蛇头等于当前蛇身中除了尾巴以外的任何节点就判断为死亡。尾巴是即将被移走的新头占了那个位置其实没问题。不过为了简单我最终的实现里仍然把所有节点都检查了一遍只是在初始化时蛇长至少为 3且实际移动过程中蛇头和尾巴在极端情况下重叠的概率极低。如果你要写严谨的版本可以在遍历时跳过最后一个节点或者更像做法是先判断新蛇头是否等于食物若不是代表下一步不会增长那尾巴一定会被移除所以允许新蛇头等于尾巴若是增长尾巴不移除那尾巴也要参与碰撞判断。4. 核心实现一段能跑起来的贪吃蛇理论基础讲完现在进入实战。我会按窗口创建、定时器、绘制、键盘控制、状态复位这几个环节把关键代码逐个拆给你看。这不是一份完整粘贴就能跑的代码但理解了这些片段你完全可以把它们拼起来。4.1 窗口注册与创建入口函数是WinMain。先注册窗口类再创建窗口进入消息循环。int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR lpCmdLine, int nCmdShow) { // 注册窗口类 WNDCLASS wc {0}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName SnakeClass; RegisterClass(wc); // 创建窗口 HWND hwnd CreateWindow(SnakeClass, 贪吃蛇 - win32API, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, MAP_W * GRID 16, MAP_H * GRID 60, NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); // 消息循环 MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return msg.wParam; }窗口尺寸这里有个容易踩坑的地方我在CreateWindow里传的宽度和高度是客户区的期望大小但窗口实际尺寸在 Windows 里还包含标题栏和边框所以直接传MAP_W * GRID会导致客户区小于预期。保守的做法是用AdjustWindowRect计算完整尺寸或者在创作初期直接用上面这种“宽高略加”的估算值。我实际用的时候加了固定值虽不精确但足够用因为后面绘制以地图区域为基准不会用窗口边缘对齐。4.2 定时器驱动的游戏主循环游戏不需要像 60 FPS 那样高频刷新。贪吃蛇每 100 毫秒移动一格手感刚刚好。我用SetTimer(hwnd, 1, 100, NULL)在WM_CREATE里启动定时器。WM_TIMER到达后先做移动再让窗口重绘。case WM_CREATE: srand((unsigned)time(NULL)); initGame(); SetTimer(hwnd, 1, 100, NULL); break; case WM_TIMER: if (gameOver) break; updateGame(); InvalidateRect(hwnd, NULL, TRUE); // 请求重绘 break;updateGame内部按前面说的顺序执行根据pendingDir更新dir计算新蛇头判断是否吃到食物执行数组移位最后生成新食物如果吃到。如果撞墙或撞自己就把gameOver置为trueInvalidateRect触发绘制层画出“游戏结束”文字。这里有一个初学者容易犯的错不调用InvalidateRect而是直接在WM_TIMER里面调用绘制函数。虽然在多数情况下界面也能更新但窗口一旦被拖动、遮挡、最大化系统就会重发WM_PAINT由于你的绘制代码不在WM_PAINT里画面就会残破或白屏。所以绘制必须统一放在WM_PAINT中WM_TIMER只负责改动数据并请求重绘。4.3 绘制逻辑从 BeginPaint 到双缓冲绘制是本项目视觉体验的决胜点。直接BeginPaint然后在窗口 DC 上画蛇每次移动时都会有明显的闪烁。原因是你每帧把整个地图擦了重画中间过程被用户看见。解决办法是内存双缓冲先在内存里画完整帧再一次拷到屏幕上。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); int winW MAP_W * GRID; int winH MAP_H * GRID; // 创建内存 DC 和位图 HDC memDC CreateCompatibleDC(hdc); HBITMAP bmp CreateCompatibleBitmap(hdc, winW, winH); HGDIOBJ oldBmp SelectObject(memDC, bmp); // 填充背景 RECT bg { 0, 0, winW, winH }; FillRect(memDC, bg, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 绘制边界墙四条边 HBRUSH wallBrush CreateSolidBrush(RGB(0, 0, 0)); HBRUSH oldBrush (HBRUSH)SelectObject(memDC, wallBrush); Rectangle(memDC, 0, 0, MAP_W * GRID, GRID); Rectangle(memDC, 0, (MAP_H - 1) * GRID, MAP_W * GRID, MAP_H * GRID); Rectangle(memDC, 0, 0, GRID, MAP_H * GRID); Rectangle(memDC, (MAP_W - 1) * GRID, 0, MAP_W * GRID, MAP_H * GRID); // 绘制食物红色实心圆 SelectObject(memDC, GetStockObject(NULL_BRUSH)); HBRUSH foodBrush CreateSolidBrush(RGB(255, 0, 0)); SelectObject(memDC, foodBrush); Ellipse(memDC, food.x * GRID 1, food.y * GRID 1, (food.x 1) * GRID, (food.y 1) * GRID); // 绘制蛇身绿色方块 SelectObject(memDC, CreateSolidBrush(RGB(0, 180, 0))); for (int i 0; i snakeLen; i) { Rectangle(memDC, snake[i].x * GRID 1, snake[i].y * GRID 1, (snake[i].x 1) * GRID, (snake[i].y 1) * GRID); } // 游戏结束文字 if (gameOver) { SetBkMode(memDC, TRANSPARENT); SetTextColor(memDC, RGB(255, 0, 0)); HFONT f CreateFont(48, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, 0, 0, 0, 0, Microsoft YaHei); HFONT oldFont (HFONT)SelectObject(memDC, f); RECT overRect { 0, 0, winW, winH }; DrawText(memDC, Game Over, -1, overRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); SelectObject(memDC, oldFont); DeleteObject(f); } // 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, winW, winH, memDC, 0, 0, SRCCOPY); // 清理资源 SelectObject(memDC, oldBrush); SelectObject(memDC, oldBmp); DeleteObject(wallBrush); DeleteObject(foodBrush); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hwnd, ps); break; }这段代码里我特意用1让方块之间留一丝缝隙视觉上更清爽。绘制蛇的时候我用了一个CreateSolidBrush用完没单独保存再删除实际项目里可以先创建一个全局的绿色画刷避免每帧创建销毁。高频率绘制时频繁创建 GDI 对象是性能短板也是句柄泄漏的隐患。严格的做法是能用GetStockObject就不用CreateSolidBrush必须创建的话在统一退出点删除。4.4 键盘控制与方向保护键盘控制乍一看很直观按下方向键就改方向。但贪吃蛇有一个隐藏的“反方向致死”问题如果当前向右玩家在极短时间内先按上、再按左由于两条按键消息都发生在下一次移动之前如果直接覆盖dir方向会先变上再变左最终被判定为向左而蛇还向右移动于是“穿体”死亡。我处理这个问题的思路是把按键方向先存到pendingDir在WM_TIMER真正移动时才把pendingDir与当前移动方向比较只接受合法方向。case WM_KEYDOWN: if (wParam VK_UP dir ! DOWN) pendingDir UP; if (wParam VK_DOWN dir ! UP) pendingDir DOWN; if (wParam VK_LEFT dir ! RIGHT) pendingDir LEFT; if (wParam VK_RIGHT dir ! LEFT) pendingDir RIGHT; if (wParam VK_SPACE gameOver) initGame(); break;然后在updateGame最开始if ((dir UP pendingDir DOWN) || (dir DOWN pendingDir UP) || (dir LEFT pendingDir RIGHT) || (dir RIGHT pendingDir LEFT)) { // 非法反向输入忽略并保留原方向 } else { dir pendingDir; }用dir还没移动的当前方向进行反向校验就能挡住那串“上 左”里的第二次按键。这里有个细节dir是上一帧已经生效的方向而在两次移动之间按键不会立即影响dir所以校验用的始终是实际移动方向。这个方案只花了四行但能解决 90% 的按键穿模问题。4.5 重新开始与状态复位游戏结束不是退出程序而是提供一个重开入口。我选择按空格键重新开始。initGame()负责把所有全局状态重置void initGame() { snakeLen 4; for (int i 0; i snakeLen; i) { snake[i].x MAP_W / 2 - i; snake[i].y MAP_H / 2; } dir RIGHT; pendingDir RIGHT; score 0; gameOver false; generateFood(); InvalidateRect(hwnd, NULL, TRUE); }初始蛇长设成 4比常见 3 更耐玩一点。食物生成之后必须保证不在蛇身上否则会出现食物和蛇头重叠却吃不到的灵异现象。generateFood用随机坐标循环校验确保生成点对齐void generateFood() { bool valid false; while (!valid) { valid true; food.x rand() % MAP_W; food.y rand() % MAP_H; for (int i 0; i snakeLen; i) { if (snake[i].x food.x snake[i].y food.y) { valid false; break; } } } }要注意srand我只在WM_CREATE里做了一次。如果在generateFood里反复调用随机数种子一样反而容易让食物在相近的几步内出现在固定区域影响公平体验。5. 实际测试中踩过的大坑与排查思路代码能跑通只是第一步。接下来我把自己真机调试时遇到的几个典型问题写出来这些问题在纯理论文档里很少讲但几乎每个人都会撞上一两个。5.1 画面闪烁都是 GDI 直绘惹的祸闪烁的根源是WM_PAINT里直接在窗口 DC 上执行了多步绘制。假设你先画背景、再画蛇、再画食物每一步系统都可能把中间结果显示出来快速刷新时人眼就看到了“残影”。双缓冲解决的就是这个问题反正最后BitBlt一次中间过程全部发生在不可见的内存里用户无从察觉。如果你的程序用了双缓冲还是闪我强烈建议检查一下BeginPaint和EndPaint是否成对出现以及InvalidateRect重绘区域设置是否合理。把重绘区域传成NULL表示整个客户区都失效简单项目无所谓但如果地图很大可以考虑只InvalidateRect蛇头和蛇尾对应的两个格子性能瞬间提升。5.2 蛇突然失控十有八九是方向状态泄露我调试时发现一种奇怪现象蛇头明明向右按了下键蛇却往下走了再按左键蛇直接往右反着穿。排查后发现问题是某个分支里把方向变量直接赋值成了wParam而VK_LEFT的值和LEFT枚举在数值上根本不对应导致方向被写成了随机值。方向控制逻辑必须自己维护不要试图拿虚拟键码直接当方向用。另外曾经出现过“按一次方向键蛇移动两格”的错觉。那是因为WM_TIMER和WM_KEYDOWN没有互斥某次绘制把蛇画到了新位置但dir更新发生在绘制之后用户看到的好像是按一下走了两步。真相是逻辑更新和渲染时序之间差了一帧。我在updateGame里先更新方向、再移动、再请求重绘顺序固定死就再没出现过这种问题。5.3 中文标题和分数显示成乱码Win32 默认字体对中文支持不太稳定。我用CreateFont指定Microsoft YaHei后中文标题和“Game Over”文字就正常了。这里要注意如果系统没装雅黑字体名匹配会失败最好在CreateFont前用EnumFontFamilies探测可用字体或者直接用兼容性最好的DEFAULT_CHARSET配合SimSun。实际项目里我倾向于写一个辅助函数先尝试雅黑失败就回退宋体。绘制分数时不能直接用printf往 DC 上输出。需要用wsprintf或者swprintf把数字格式化成字符串再配合TextOut输出。我在WM_PAINT里同时绘制了地图、分数文字和游戏状态说明文字放到坐标(10, MAP_H * GRID 10)的区域这部分被我单独画在窗口 DC 上因为双缓冲区域只有地图大小分数显示在缓冲区外。5.4 常见错误与排查速查我把调试阶段最常遇到的几个问题整理成了一张表方便你对照检查。现象可能原因排查思路窗口创建失败返回 NULL窗口类名未注册或拼写不一致检查RegisterClass返回值确认类名完全一致窗口白屏无内容绘制代码不在WM_PAINT里把绘制统一移到WM_PAINT用InvalidateRect请求重绘蛇移动闪烁严重直接绘制在窗口 DC 上使用内存 DC 双缓冲一次BitBlt按方向键蛇反向穿体没做方向保护用pendingDir延迟生效比较当前实际移动方向食物出现但蛇吃不到食物坐标在蛇身上生成食物后遍历蛇身校验游戏结束无法重开状态没重置编写initGame()一次性重置所有全局变量窗口尺寸不对CreateWindow宽高未包含非客户区用AdjustWindowRect计算正确窗口矩形分数文字乱码字体不支持中文或编码错误使用CreateFont指定中文字体用宽字符字符串程序退出不干净GDI 对象泄漏每帧创建的对象必须成对释放建议全局复用画刷最后再分享一个我自己的习惯调试窗口程序时MessageBox是我的第一调试工具。逻辑出错时弹窗输出关键变量比瞎猜效率高得多。当你确定某段逻辑有问题时在可疑位置加一个MessageBox把snakeLen、snake[0]、food的值打出来百分之八十的问题当场就暴露了。写这个贪吃蛇 demo 的过程里我最大的体会是“程序框架”和“游戏逻辑”要分开想。win32API 提供的是壳真正让游戏好玩的蛇身移动、碰撞判定是你自己的核心算法。很多人卡壳是因为一边纠结 API 调用细节一边又想着游戏玩法两边都没抓好。我建议你先用命令行或者纯逻辑把贪吃蛇的移动和碰撞写通再套上 Win32 窗口外壳这样排查起来会轻松非常多。先有逻辑后有界面这个顺序我屡试不爽。