简介面向C初学者与课程设计学习者的贪吃蛇游戏增强版工程源码基于VC6.0与MFC实现适合用来理解Windows图形界面游戏的基本开发流程。工程围绕蛇体类与链表节点类的“双类”设计展开将蛇的位置、长度与移动、增长等行为封装起来并用单链表动态维护蛇身便于复习节点插入、删除与遍历等数据结构知识。包内共58个文件以cpp、h源码和obj、sbr编译中间文件为主另含bmp位图、ico图标、rc资源脚本及可执行文件压缩包约8.98MB保留完整VC6.0工程结构与调试产物。已有180人学习可参考其键盘消息处理、定时器控制速度、界面绘制、游戏状态切换和异常退出等实现思路并对照工程组织方式完成编译、调试与二次修改。1. 打开就编译一个 VC6 贪吃蛇工程凭什么还值得拆前段时间有人丢过来一个.dsw文件说双击能打开、F7 能编译但蛇跑起来像在跳帧。我原本以为又是那种大学课程设计式的半成品——一个main.cpp塞两千行全局变量满天飞。结果拆开发现不是这个用 C 和 VC6 写的贪吃蛇增强版把定时器驱动、环形数组存蛇身、双缓冲抗闪烁这三件事做得很干净增强部分加速、穿墙、障碍关卡、最高分存档也没有把主循环搅烂。真正值钱的地方在骨架不在玩法。二十多年过去Win32 GDI 依然是理解“一个游戏循环长什么样”最短的那条路而这份代码正好是一个不掺框架杂质的最小样本。想练 C 手感、想搞懂消息循环、想给老项目做二次开发的人都能从里面拿到东西。2. 工程结构从 .dsw 到 GDI 的那条链路VC6 的工程组织和现在 CMake 那一套完全不同它靠.dsp项目文件和.dsw工作区文件描述“哪些 cpp 参与编译、链接哪些 lib、输出什么类型”。很多人拿到这种老工程第一反应是新建一个 VS 工程把源码拖进去结果编译过了、跑起来一堆警告——因为字符集、WINVER宏、链接库这些东西全丢了。先看清结构再动手比急着按 F5 划算。2.1 目录与文件职责一份结构正常的贪吃蛇增强版工程职责划分通常是这样文件职责改动频率Snake.dsw/Snake.dspVC6 工作区与工程配置含链接库列表几乎不动main.cppWinMain、窗口类注册、消息循环低wndproc.cppWndProc、定时器、键盘分发、状态切换中game.h/game.cpp蛇身数据、移动、食物、碰撞、状态机高render.cppGDI 双缓冲绘制网格、蛇、食物、HUD中level.h关卡地图用字符串数组描述高res/图标、wav音效资源低这个划分的关键点是游戏逻辑不碰 GDI绘制层不碰游戏状态机。game.cpp里只有坐标和格子render.cpp里只有Rectangle、Ellipse、BitBlt。二次开发时想换皮肤只动render.cpp想加玩法只动game.cpp。如果拆开发现逻辑和绘制搅在一起那第一步就该做这个分离不然后面每加一个功能都要碰一遍绘图代码。提示level.h单独成文件是个好习惯。地图用字符串画一行就是屏幕上一行改关卡不需要重新理解一遍碰撞算法。2.2 消息循环与定时器游戏的心跳Win32 里没有“每帧回调”这种概念所有节奏都靠SetTimer和WM_TIMER撑起来。这是整套代码里最值得先读懂的一段// main.cpp —— 标准 Win32 骨架没有 MFC依赖最少 int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmdLine, int nCmdShow) { WNDCLASSEX wc { sizeof(WNDCLASSEX) }; 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 SnakeWnd; RegisterClassEx(wc); // 客户区固定 600x480 30 格 x 24 格 x 20 像素 // 注意 WS_OVERLAPPEDWINDOW 带边框窗口总尺寸会比客户区大 HWND hWnd CreateWindowEx(0, SnakeWnd, Snake Enhanced, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 600 16, 480 38, NULL, NULL, hInst, NULL); ShowWindow(hWnd, nCmdShow); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { // 阻塞式取消息 TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam; }GetMessage是阻塞的没有消息时线程就挂在那儿不烧 CPU这一点和现在游戏引擎的“忙等 垂直同步”完全不同。节奏全靠WM_TIMER推定时器到点了系统往队列里塞一条消息DispatchMessage送到WndProc里面调一次GameTick。所以这里有个绕不开的事实——游戏速度 定时器间隔想让蛇跑快改的就是这个毫秒数。CreateWindowEx里那个16 / 38是边框和标题栏的补偿。这个数字随系统主题变硬编码在 XP 上刚好在 Win10 上可能会差几个像素导致客户区被裁掉一点。稳妥的做法是先CreateWindowEx再用GetClientRect取实际尺寸反算格子大小或者直接用AdjustWindowRectEx。2.3 为什么不用 MFC 文档视图VC6 时代建工程向导默认给你两套选项MFC AppWizard 和 Win32 Application。这份代码选的是后者理由很实在。MFC 的文档/视图结构是为“打开文件、编辑、保存”这类应用设计的CDocument管数据、CView管显示、OnDraw负责画。拿来写贪吃蛇你会被迫把蛇的状态塞进CDocument把绘制塞进OnDraw再为了定时器重绘去调Invalidate绕了一大圈还在跟CDC指针打交道。真正需要的东西只有三个一个窗口句柄、一个定时器、一块能画的内存 DC。Win32 直接给。代价是得自己管资源释放。CreateCompatibleDC出来的 DC 和CreateCompatibleBitmap出来的位图都是 GDI 对象数量有上限默认每个进程 10000 个句柄忘了DeleteObject跑久了就会画不出来。MFC 用CDC封装帮你兜了一部分但也只是兜了一部分。自己写反而清楚——谁创建谁释放写在注释里就行。另外纯 Win32 工程编译出来的 exe 只有几十 KB扔 U 盘里就能跑不依赖任何运行时。这个特性在老机器、教学机、离线环境里依然好用。3. 核心模块拆解蛇身、转向、食物、碰撞与渲染逻辑层是这份代码里我最愿意推荐的部分。贪吃蛇看着简单真写起来全是边界蛇怎么存、转向怎么排队、食物怎么避开蛇身、撞自己怎么判定、尾巴那格算不算障碍。这五件事任何一件处理糙了玩起来就是“玄学”——有时候能过有时候莫名其妙就死了。3.1 蛇身数据结构环形数组代替链表新手最容易写出来的版本是std::vectorPos或者链表前进的时候push_front新头、pop_back旧尾。VC6 的 STL 实现又老又慢链表的节点分散在堆上每帧遍历一遍画图cache 命中率惨不忍睹。更稳的是环形数组一块定长数组头指针和尾指针在上面转圈前进一格就是“头指针 1 写入新坐标尾指针 1 丢弃旧尾巴”全是 O(1)没有内存分配。// game.h —— 棋盘与蛇身的基础定义 #define COLS 30 // 横向格子数 #define ROWS 24 // 纵向格子数 #define CELL 20 // 单格像素客户区 600x480 #define MAX_LEN (COLS * ROWS) // 蛇最长不超过格子总数 struct Pos { int x; int y; }; struct Snake { Pos body[MAX_LEN]; // 定长数组栈上或全局不动态分配 int head; // 蛇头所在下标 int tail; // 蛇尾所在下标 int nLen; // 当前长度 int dir; // 方向0 上 1 右 2 下 3 左 };三个下标的关系是nLen决定蛇有多长head和tail决定这nLen个格子落在数组的哪一段。当nLen MAX_LEN时数组是满的头尾重合这时候不该再增长——满屏就该判通关了。用环形数组有两个必须记住的坑。第一任何时候遍历蛇身都要用(tail i) % MAX_LEN不能直接for (i 0; i nLen; i)因为数组在逻辑上是“绕”的。第二MAX_LEN必须严格大于等于可能出现的最大长度否则新头会覆盖旧身画面上的蛇会自己断成两截还不报错。提示MAX_LEN直接取COLS * ROWS最省心。贪吃蛇占满全屏本来就是通关条件不会真的超。3.2 转向输入队列为什么不能直接改方向这一条是血泪经验。新手版本通常是WM_KEYDOWN里直接snake.dir VK_UP本地跑得好好的一到手速快的玩家手里就翻车一帧之内连按上、左蛇头先朝上再朝左等于原地掉头直接撞自己身上玩家一脸懵。根因是键盘消息的到达频率和定时器的频率不是一个量级。定时器 150ms 一次键盘随手就能 30ms 一个一帧里塞两三个方向太正常了。所以必须有个队列把玩家输入缓存起来每帧只消费一个。// game.cpp —— 两级输入缓冲 static int g_dirQueue[2] { -1, -1 }; // -1 表示空槽 void PushDir(int d) { // 取队列里最后一个有效方向作为比较基准 int last (g_dirQueue[1] ! -1) ? g_dirQueue[1] : g_dirQueue[0]; if (last -1) last g_snake.dir; // 禁止 180 度掉头上(0)与下(2)、左(3)与右(1)互为反方向 if ((d 2) % 4 last) return; if (g_dirQueue[0] -1) g_dirQueue[0] d; else if (g_dirQueue[1] -1) g_dirQueue[1] d; // 两槽都满就丢弃宁可丢输入也不能让蛇抽风 } int PopDir() { int d g_dirQueue[0]; g_dirQueue[0] g_dirQueue[1]; g_dirQueue[1] -1; return d; // 返回 -1 表示本帧无输入 }参数上有两个细节值得说。第一方向编码用0/1/2/3对应上右下左反方向就是(d 2) % 4这个映射关系一旦定下来后面所有判断都能写成算术不用switch。第二判断基准取的是队列里最后一个方向而不是当前方向否则“上→右→下”这种连续输入里玩家按下时蛇头可能还没转到右就会被误判成合法掉头。只给两个槽位是有意的。多了没用——人手的反应速度撑不起一帧三次有效转向少了会丢输入。两个刚好覆盖“这一帧的转向 下一帧的预输入”。3.3 食物生成随机不算完还要排除蛇身最朴素的写法是do { food random(); } while (OnSnake(food));。蛇短的时候没问题蛇长到占满半屏这个循环会反复空转到让人怀疑人生表现出来就是“吃到食物后卡一下”。正确做法是先算出所有空格子再从里面随机挑一个// game.cpp —— 均匀、无重试的食物生成 Pos SpawnFood(const Snake* s) { static char occupied[ROWS][COLS]; memset(occupied, 0, sizeof(occupied)); // 注意用 (tail i) % MAX_LEN 遍历环形数组不能顺序扫 for (int i 0; i s-nLen; i) { int idx (s-tail i) % MAX_LEN; occupied[s-body[idx].y][s-body[idx].x] 1; } int freeCnt 0; static Pos cand[MAX_LEN]; for (int y 0; y ROWS; y) for (int x 0; x COLS; x) if (!occupied[y][x]) { cand[freeCnt].x x; cand[freeCnt].y y; freeCnt; } if (freeCnt 0) { Pos none { -1, -1 }; return none; } // 满屏即通关 return cand[rand() % freeCnt]; }cand和occupied都加了static是为了避开 VC6 默认 1MB 栈空间——Pos cand[720]是 5.7KB单次调用没事但如果在递归或者深层调用里反复开栈容易顶到上限。放静态区一次分配后面反复用。随机种子别忘了。rand()不给种子的话每次启动序列一模一样玩家会发现“第一次的食物永远在右上角”。在WM_CREATE里调一次srand((unsigned)time(NULL))就够了别每帧调那样同一秒内的多次调用拿到的是同一个值。3.4 碰撞与胜负判定碰撞要分三类撞墙、撞自己、撞障碍物。三者的判定顺序和边界都不一样混在一起写容易出“明明没撞却死了”的怪事。// game.cpp —— 返回值0 安全 1 撞墙 2 撞自己 3 撞障碍 int CheckCollision(const Snake* s, int level) { Pos h s-body[s-head]; // 1. 撞墙穿墙模式开启时这一段整体跳过 if (!g_bWrap) { if (h.x 0 || h.x COLS || h.y 0 || h.y ROWS) return 1; } // 2. 撞自己从 head-1 往前数 nLen-1 个尾巴那格不算 for (int i 1; i s-nLen; i) { int idx (s-head - i MAX_LEN) % MAX_LEN; if (s-body[idx].x h.x s-body[idx].y h.y) return 2; } // 3. 撞障碍关卡地图上的静态格子 if (g_levelMap[level][h.y][h.x] 1) return 3; return 0; }i从 1 开始、到nLen - 1结束这个范围是关键。蛇头自己当然不能和自己比所以要跳过i 0而尾巴那一格在下一步就要挪走了理论上蛇头可以进去所以也不比。如果老老实实从 0 比到nLen-1蛇在贴近自己身体转弯时会莫名其妙死亡这是最常见的翻车点之一。穿墙的处理放在移动那一步更合适而不是在碰撞里补救// 移动一格返回是否吃到食物 int SnakeStep(Snake* s, Pos food) { static const int DX[4] { 0, 1, 0, -1 }; static const int DY[4] { -1, 0, 1, 0 }; Pos nh s-body[s-head]; nh.x DX[s-dir]; nh.y DY[s-dir]; if (g_bWrap) { // 穿墙取模回到另一侧 nh.x (nh.x COLS) % COLS; nh.y (nh.y ROWS) % ROWS; } int grow (nh.x food.x nh.y food.y); s-head (s-head 1) % MAX_LEN; s-body[s-head] nh; if (s-nLen MAX_LEN) s-nLen; if (!grow) s-tail (s-tail 1) % MAX_LEN; // 不吃就不删尾 return grow; }(nh.x COLS) % COLS里的 COLS不能省。C 语言里-1 % 30结果是-1不是 29直接写nh.x % COLS从左边界穿出去会变成负坐标然后在下一帧被判定成撞墙。3.5 双缓冲渲染把闪烁摁死GDI 直接往窗口 DC 上画每画一个矩形屏幕就刷一次蛇一动整片区域重绘肉眼看到的就是闪。解决办法是老三样内存 DC 上画完整帧再一次性BitBlt贴过去。// render.cpp —— 双缓冲句柄只创建一次 static HDC g_memDC NULL; static HBITMAP g_memBmp NULL; void InitBackBuffer(HWND hWnd) { HDC hdc GetDC(hWnd); g_memDC CreateCompatibleDC(hdc); g_memBmp CreateCompatibleBitmap(hdc, COLS * CELL, ROWS * CELL); SelectObject(g_memDC, g_memBmp); // 换掉默认的 1x1 单色位图 ReleaseDC(hWnd, hdc); } void DrawFrame(HWND hWnd) { RECT rc { 0, 0, COLS * CELL, ROWS * CELL }; FillRect(g_memDC, rc, (HBRUSH)GetStockObject(BLACK_BRUSH)); DrawGrid(g_memDC); // 淡色网格线 DrawLevel(g_memDC); // 障碍墙 DrawFood(g_memDC, g_food); // 食物 DrawSnake(g_memDC, g_snake); // 蛇头深尾浅 HDC hdc GetDC(hWnd); BitBlt(hdc, 0, 0, COLS * CELL, ROWS * CELL, g_memDC, 0, 0, SRCCOPY); ReleaseDC(hWnd, hdc); }配合WndProc里一句case WM_ERASEBKGND: return 1;闪烁基本就没了。这句的意思是“背景我自己擦系统你别管”少了它系统会在WM_PAINT之前先用背景刷子刷一遍白双缓冲白做了。还有个细节SelectObject之后拿到的旧位图对象那张 1×1 单色位图不需要保存但g_memBmp在窗口销毁时必须DeleteObject。少了这一步每次重开一局就泄漏一个位图句柄跑上几十次之后CreateCompatibleBitmap会返回 NULL画面直接全黑。4. 增强点怎么落地加速、穿墙、障碍、存档基础版能玩之后“增强”才是这份代码真正的分水岭。但增强最容易搞成堆砌——加一堆开关代码里到处if (g_bXXX)最后连自己都记不清哪条路径。稳妥的做法是先把状态机理清楚再把每个增强点做成一块可以独立开关的模块。4.1 速度曲线与定时器重设提速不是改一个变量就完事它同时牵扯定时器、分数、玩家预期三件事。// game.cpp —— 吃到食物后的处理 void OnEatFood(HWND hWnd) { g_nScore 10; g_nEatCnt 1; // 每吃 4 个提速一档间隔减 10ms最快 60ms 封顶 if (g_nEatCnt % 4 0 g_nInterval 60) { g_nInterval - 10; // 同一个 ID 再调 SetTimer 是替换不是新增旧的自动失效 SetTimer(hWnd, IDT_GAME, g_nInterval, NULL); } g_food SpawnFood(g_snake); if (g_food.x 0) { g_state GS_WIN; KillTimer(hWnd, IDT_GAME); } }SetTimer的行为值得单独说一句用同一个定时器 ID 再调一次是重置而不是叠加。所以这里不用先KillTimer。但如果用了不同的 ID就会同时存在两个定时器WM_TIMER的wParam分别对应蛇会跑出双倍速度——这是很多人调速度时踩的坑。60ms 封顶是从手感出发的再快人类的预判就跟不上了格子只有 20 像素蛇一帧跳一格眼睛根本追不住。想要更高的难度感靠加速度曲线不如靠障碍物密度。参数含义推荐取值改动影响CELL单格像素16 / 20 / 24改了要同步窗口客户区尺寸COLS×ROWS棋盘格数30 × 24影响难度与可视面积g_nInterval定时器间隔ms150 起60 封顶直接决定蛇速提速步长每档减多少 ms10太大手感突变太小没感知提速周期每吃几个提一档4影响难度爬升曲线4.2 障碍物关卡的数据描述别用if (level 1) { ... }一关一关写死。用字符串画地图一行字符串就是屏幕上的一行1是墙、0是空地// level.h —— 一张图就是一关改关卡不用碰逻辑代码 static const char* g_levelMap[3][ROWS] { { // 第 0 关空场只有四周边界 111111111111111111111111111111, 100000000000000000000000000001, 100000000000000000000000000001, // ... 中间 20 行同理 100000000000000000000000000001, 111111111111111111111111111111 }, { // 第 1 关中间一道竖墙留两个缺口 111111111111111111111111111111, 100000000000000000000000000001, 100000000001111111000000000001, // 缺口在两侧 // ... 其余行略 111111111111111111111111111111 }, { // 第 2 关十字形障碍 111111111111111111111111111111, 100000000000011000000000000001, 100000000000011000000000000001, // ... 其余行略 111111111111111111111111111111 } };这种写法的好处是所见即所得改一关的难度直接改字符串里的0和1不需要理解碰撞算法也不怕改出界。代价是每条字符串必须是COLS个字符多一个少一个都会越界读——初始化时加一句assert(strlen(g_levelMap[l][y]) COLS)能提前发现问题。注意第 0 关把四周边界也画成墙了。这时候如果同时开了穿墙模式取模会把你送到边界墙上一穿就死。所以穿墙模式要么不允许在带边界墙的关卡开启要么在碰撞检测里跳过边界那两行。我在自己改的版本里干脆把边界墙去掉让COLS/ROWS自己就是边界逻辑更干净。4.3 最高分落盘存档最简单的方式就是读写一个小文本文件别上数据库、别上注册表。// game.cpp —— 最高分读写 void LoadBest() { char path[MAX_PATH]; GetModuleFileName(NULL, path, MAX_PATH); // 取 exe 全路径 char* p strrchr(path, \\); if (p) strcpy(p 1, best.ini); // 同目录下换文件名 FILE* fp fopen(path, r); if (fp) { fscanf(fp, %d, g_nBest); fclose(fp); } if (g_nBest 0) g_nBest 0; // 防止文件被写坏 } void SaveBest() { if (g_nScore g_nBest) return; // 没破纪录就不写盘 g_nBest g_nScore; char path[MAX_PATH]; GetModuleFileName(NULL, path, MAX_PATH); char* p strrchr(path, \\); if (p) strcpy(p 1, best.ini); FILE* fp fopen(path, w); if (fp) { fprintf(fp, %d\n, g_nBest); fclose(fp); } }为什么不用fopen(best.ini, r)这种相对路径因为相对路径的基准是“当前工作目录”而不是 exe 所在目录。双击运行的时候两者恰好一样但从 IDE 里 F5 跑、或者从快捷方式启动工作目录可能是别的地方就会出现“明明玩出了高分重开一局又归零”的诡异现象。GetModuleFileName拼绝对路径能一次解决。写文件记得判空。fopen在只读目录、权限受限的时候会返回 NULL直接fprintf(NULL, ...)在 VC6 里是崩溃而不是报错。4.4 暂停与重开的状态机增强功能一多状态就容易乱。用一个枚举把状态收拢所有输入和定时器逻辑先看状态再决定动不动// game.h enum GameState { GS_READY, GS_RUNNING, GS_PAUSED, GS_OVER, GS_WIN }; // wndproc.cpp —— 定时器里只处理 RUNNING case WM_TIMER: if (wParam IDT_GAME) { if (g_state GS_RUNNING) { int d PopDir(); if (d ! -1) g_snake.dir d; GameTick(hWnd); } } return 0; case WM_KEYDOWN: switch (wParam) { case VK_UP: if (g_state GS_RUNNING) PushDir(0); break; case VK_RIGHT: if (g_state GS_RUNNING) PushDir(1); break; case VK_DOWN: if (g_state GS_RUNNING) PushDir(2); break; case VK_LEFT: if (g_state GS_RUNNING) PushDir(3); break; case VK_SPACE: if (g_state GS_RUNNING) { g_state GS_PAUSED; } else if (g_state GS_PAUSED) { g_state GS_RUNNING; } break; case R: GameReset(hWnd); g_state GS_RUNNING; break; } return 0;暂停的时候g_dirQueue里的残留要给清掉不然恢复那一刻蛇会按暂停前最后一个输入拐弯玩家会觉得“我明明没按”。GameReset里顺手g_dirQueue[0] g_dirQueue[1] -1;就干净了。5. 踩坑排查VC6 下跑这套代码最容易翻车的五处这一节按“现象 → 原因 → 解决”写都是我自己在这类老工程上真遇过的。5.1 编译报 LNK2001找不到PlaySound现象代码里写了PlaySound(eat.wav, NULL, SND_FILENAME | SND_ASYNC);头文件也包含了mmsystem.h编译阶段不报错链接阶段报LNK2001: unresolved external symbol __imp__PlaySoundA。原因PlaySound属于 WinMMVC6 的 Win32 Application 默认链接的是kernel32.lib user32.lib gdi32.lib这几个基础库不含winmm.lib。解决Project → Settings → Link → Object/library modules那一行末尾补上winmm.lib前面留一个空格。或者在源文件顶部加一句#pragma comment(lib, winmm.lib)跟着代码走换机器也不用重新配。5.2 窗口一片黑蛇根本不出来现象程序起来了窗口是黑的网格都没有。原因有三处可能按概率排——一是WM_PAINT里没调DrawFrame或者调了但 FM 里BeginPaint/EndPaint没配对导致整块客户区被判定为“无效”二是画图时坐标忘了乘CELL三十来条线全挤在左上角几个像素里三是WM_ERASEBKGND返回了 0与双缓冲互相打架。解决先在DrawFrame末尾加一个Rectangle(g_memDC, 0, 0, 100, 100)当哨兵红色方块出现了说明双缓冲链路是通的问题在数据层没出现就往上查WM_PAINT。坐标问题最简单——DrawSnake里所有x * CELL、y * CELL都检查一遍漏乘一个就会全画到一个点上。5.3 蛇一转弯就撞死自己现象长度到 4 以后快速按键拐弯时不时直接判死位置看起来没撞到任何东西。原因方向是在WM_KEYDOWN里直接写给g_snake.dir的一帧内收到两次按键蛇头就按“先下后左”走了一步后再走一步第二步正好在原来身体的位置上。定时器 150ms 一帧人手一次双击就跨过去了。解决改成 3.2 节那种两级输入队列每帧只出队一个方向同时在PushDir里用(d 2) % 4 last拦掉 180 度掉头。改完之后手速再快也只会“拐一次”多余输入被丢弃不会出现幽灵式自撞。5.4 分数一高画面就开始卡越来越高现象前几百分流畅蛇长到五六十节以后明显掉帧切到任务管理器一看 GDI 对象数一直涨。原因DrawFrame里每次调用都CreateCompatibleDCCreateCompatibleBitmap用完只ReleaseDC没DeleteObject。GDI 句柄有配额涨到上限之后创建全部失败绘制就退化成全黑。解决把内存 DC 和位图挪到WM_CREATE里创建一次全局保存WM_DESTROY里DeleteObject(g_memBmp)加DeleteDC(g_memDC)。顺便在调试版里加个GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)打印到标题栏能直观看到句柄数有没有稳定。5.5 改完地图编译通过一跑就崩在CheckCollision现象新加了一关编辑完字符串编译没错进游戏切到那一关直接触发访问冲突。原因地图字符串长度和COLS不一致。少一个字符g_levelMap[level][y][29]就读到了字符串结束符\0之外的区域多一个字符整行错位后面所有行的判定全乱。解决初始化时统一校验assert(strlen(g_levelMap[l][y]) COLS)在 Debug 下第一次跑就能定位到具体是哪一行。另外别用中文字符或者全角空格去排版地图VC6 默认把源文件当本地编码读一个中文字符占两字节strlen数出来的值永远对不上。6. 二次开发把这套骨架搬到自己的练手项目上我最推荐的一个改造方向是把渲染层彻底抽出来。现在DrawSnake里是Rectangle画方格子改成贴图只需要换一个函数准备一张snake_head.bmp、一张snake_body.bmp用LoadImage加载成HBITMAP再用SelectObject换进一个临时 DCBitBlt贴到g_memDC上。逻辑层一行都不用改因为Snake里只有格子坐标不知道外面是矩形还是图片。// render.cpp —— 用位图替换色块逻辑层零改动 static HBITMAP g_bmpHead NULL; static HBITMAP g_bmpBody NULL; void LoadSkin() { g_bmpHead (HBITMAP)LoadImage(NULL, res\\head.bmp, IMAGE_BITMAP, CELL, CELL, LR_LOADFROMFILE); g_bmpBody (HBITMAP)LoadImage(NULL, res\\body.bmp, IMAGE_BITMAP, CELL, CELL, LR_LOADFROMFILE); } void DrawSnake(HDC dc, const Snake* s) { HDC tmp CreateCompatibleDC(dc); for (int i 0; i s-nLen; i) { int idx (s-tail i) % MAX_LEN; HBITMAP src (i s-nLen - 1) ? g_bmpHead : g_bmpBody; if (!src) { // 图没加载上就退回色块 RECT r { s-body[idx].x * CELL, s-body[idx].y * CELL, (s-body[idx].x 1) * CELL, (s-body[idx].y 1) * CELL }; FillRect(dc, r, (HBRUSH)GetStockObject(WHITE_BRUSH)); continue; } HGDIOBJ old SelectObject(tmp, src); BitBlt(dc, s-body[idx].x * CELL, s-body[idx].y * CELL, CELL, CELL, tmp, 0, 0, SRCCOPY); SelectObject(tmp, old); // 一定要还回去 } DeleteDC(tmp); }SelectObject之后必须把旧对象还回去再DeleteDC这是 GDI 的硬规矩忘了还就会出现“位图被删了但 DC 还引用着”的悬空句柄。if (!src)那个兜底也别省——图丢了程序还能跑只是丑一点比直接崩强。第二个方向是加一个回放模式把每一帧的方向和食物位置记进一个struct { short dir; short foodX, foodY; }数组游戏结束后按原顺序重放。这个功能对调试特别有用蛇莫名其妙死了回放一遍就能看清是哪一帧的输入导致的问题比盯着屏幕猜强得多。第三个方向是把定时器换掉。SetTimer的精度受系统时钟节拍限制默认 15.6ms 一跳你设 60ms 实际可能是 62 或 78高速时手感会飘。想稳就用timeGetTime()自己在消息循环里累加时间差WM_TIMER只保留一个 15ms 的心跳做驱动。这一改速度曲线才真正可控。改造项改动位置风险换贴图皮肤render.cpp低位图丢失要有兜底回放模式game.cpp加录制缓冲中缓冲要定长换高精度计时消息循环 WM_TIMER中注意别忙等烧 CPU加双人模式输入队列拆两份Snake实例化高碰撞判定要改成双向双人模式我试过一次最麻烦的不是键盘分区而是“两条蛇头对头撞在一起”该怎么判。现在的逻辑是“先移动先判定”谁先到GameTick谁就吃掉对方结果是先手必胜。公平的做法是把两条蛇的下一步都算出来再统一判定等于给GameTick加一个两阶段提交。这个改动会动到主循环结构改之前一定先把单人的测试跑通、把回放录好出问题能立刻对比。从那以后我每次动这类老工程都强制先跑一遍“最小可玩”回归开局、吃三个食物、撞一次墙、按一次暂停、重开一局五步全过再动新功能。这套流程救过我好几回尤其是改地图和改渲染同时进行的时候。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
Spark二手车价格预测系统:机器学习+可视化分析实战 1. 项目概述:这不是一个“炫技Demo”,而是一套能真正跑在二手车商办公室里的分析系统我做大数据平台落地已经十多年,经手过几十个行业项目,最怕听到客户说“这个模型很准,但我不知道怎么用”。这套基于Spark的二手车市… · 2026/11/2 13:26:31
Trae AI编程助手:从代码补全到智能协同的范式跃迁 1. Trae如何颠覆传统AI编程工具范式第一次接触Trae时,我正被一个复杂的微服务架构问题困扰。与以往AI编程助手不同,Trae没有让我反复描述需求,而是直接分析代码上下文后,主动给出了包含分布式事务处理的完整解决方案。这种从"… · 2026/11/3 15:18:40
无人机容错控制:遗传算法优化滑模与RBF神经网络应用 1. 项目背景与核心挑战四旋翼无人机在复杂环境下的故障容错控制一直是飞行控制领域的难点问题。当无人机遭遇单臂结构故障或电机异常时,传统PID控制器往往难以维持稳定飞行。我在实际项目中多次遇到这类场景——比如去年测试的农业植保无人机,在喷洒作业… · 2026/11/2 13:26:31
硬件测试入门指南:从检视、FMEA到分层测试的完整流程 简介:这份PPT面向硬件测试方向的入门者与初级测试工程师,系统梳理硬件测试的理念、目的、方法与流程,帮助读者建立从测试前准备到问题解决的完整认知框架。内容涵盖硬件测试概述、测试前准备、测试种类与操作、测试级别、可靠性测试、问题解决… · 2026/11/3 21:49:51
MySQL主从2秒快速切换方案设计与实践 1. MySQL主从切换的痛点与核心挑战每次遇到数据库故障需要切换主从时,DBA们总会面临几个经典难题:业务中断时间长、数据一致性难保证、切换过程不可逆。传统的主从切换流程通常需要执行十几个步骤,整个过程动辄需要5-10分钟,这对现… · 2026/11/3 21:49:51
Windows平台QT开发环境配置与VS2019集成指南 1. QT开发环境安装概述QT作为一款跨平台的C图形用户界面应用程序开发框架,在工业控制、嵌入式系统、桌面应用等领域有着广泛应用。对于刚接触QT的开发者来说,环境搭建往往是第一个需要跨越的门槛。本文将详细介绍在Windows平台上使用VS2019作为IDE的QT开… · 2026/11/3 21:49:51
制造业数字化转型中的智能拧紧工艺优化 1. 拧紧工艺在制造业中的核心地位在汽车装配线上,一颗螺栓的拧紧力矩偏差可能导致整车异响;在航空航天领域,关键部位的连接松动可能引发灾难性事故。这些看似简单的拧紧操作,实则是产品质量与安全的第一道防线。根据国际质量协会统… · 2026/11/3 21:49:51
从零构建AI工程体系:环境初始化到可观测性的全链路实践 1. 为什么“从零构建AI工程体系”不是写个模型训练脚本那么简单很多人看到“AI Engineering from Scratch”这个标题,第一反应是:不就是用PyTorch跑个ResNet,再加个Flask接口?我试过——去年帮一家做工业质检的客户搭整套推理流水… · 2026/11/3 21:49:51
分布式文件系统核心架构与优化实践 1. 分布式文件系统概述分布式文件系统(Distributed File System, DFS)是现代计算架构中不可或缺的基础设施组件。它通过将文件存储和管理功能分布在多个服务器节点上,实现了数据的高可用性、可扩展性和容错能力。与传统的本地文件系统相比&am… · 2026/11/3 21:48:51
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46