简介:面向C++与游戏开发初学者的贪吃蛇增强版项目源码,基于VC6.0与MFC实现,适合用来练习面向对象封装、链表数据结构、Windows消息处理与定时器控制。压缩包共58个文件、约8.98MB,包含cpp/h源码、工程与资源文件、bmp/ico素材、exe可执行文件及obj/sbr等编译中间产物,便于直接运行、对照调试和二次修改。项目将蛇体抽象为独立类并以单链表维护节点,涵盖食物生成、碰撞判定、速度调节、开始/暂停/结束状态管理以及键盘事件响应等内容。已有179人学习,可作为课程设计、C++实训或Windows图形界面入门案例,帮助读者理解从源码组织、编译调试到游戏逻辑验证的完整过程。
1. 从一份 VC6 贪吃蛇增强版源码说起:它增强在哪、适合谁
贪吃蛇 c++ vc6 这套组合放在今天看有点复古,但拿它练手 Win32 消息循环、GDI 绘图和定时器,比一上来啃框架更直观。这份增强版源码包不是把经典贪吃蛇换个皮,而是在原始移动、吃食物、计分的基础上塞进了障碍物、道具、速度等级和暂停重开,工程仍然是 VC6.0 那一套 .dsp/.dsw 结构。适合两类人:一是刚学完 C++ 语法、想找个能编译能跑的小项目把消息映射和数组队列串起来的新手;二是手头还有老工程、需要维护或二次开发 VC6 代码的从业者。它解决的不是“贪吃蛇怎么写”这个入门问题,而是“老 IDE 下怎么把一个小游戏拆成可扩展模块”的落地问题。源码包里的工程配置、资源路径和游戏状态机,比玩法本身更值得先拆一遍。
2. VC6 工程落地:从 .dsw 到可执行文件的编译链路
2.1 先判断工程类型:Win32 还是 MFC
拿到源码包,别急着双击 .dsw 就按 F7。先看工程目录里有没有StdAfx.cpp、MainFrm.cpp、*View.cpp,再看 .dsp 里是否带_AFXDLL或Use MFC in a Shared DLL。带这些通常是 MFC 工程,入口是CWinApp::InitInstance,消息靠消息映射表;不带则是 Win32 工程,入口是WinMain,消息在WndProc里用switch分发。两种工程都能做贪吃蛇,但改法不一样:MFC 工程改OnTimer、OnKeyDown和OnPaint,Win32 工程改WM_TIMER、WM_KEYDOWN和WM_PAINT。认错工程类型,后面加功能会一直找不到该改哪个函数。
判断方法不复杂:打开 .dsp,搜ADD BASE CPP那几行,看看预处理宏里有没有_AFXDLL;再搜SOURCE=,看有没有MainFrm.cpp。如果工程里只有SnakeApp.cpp、SnakeWnd.cpp、GameCore.cpp,大概率是 Win32 封装。增强版为了移植方便,常见做法是把游戏逻辑单独放在GameCore.cpp,界面层只负责收消息和绘图。这种分层对二次开发最友好,加障碍、加道具时不用碰窗口过程。
| 判断项 | Win32 工程 | MFC 工程 |
|---|---|---|
| 入口 | WinMain | CWinApp::InitInstance |
| 消息处理 | WndProc里switch(msg) | BEGIN_MESSAGE_MAP映射 |
| 定时器 | WM_TIMER | OnTimer(UINT_PTR) |
| 绘图 | WM_PAINT+BeginPaint | OnPaint+CPaintDC |
| 资源加载 | LoadBitmap、LoadIcon | CBitmap::LoadBitmap、CString::LoadString |
表格里的UINT_PTR在 VC6 里通常写成UINT,因为 VC6 的 MFC 版本较老,OnTimer参数类型是UINT nIDEvent。如果你从新版本代码里抄了UINT_PTR,编译会报类型不匹配,改成UINT即可。这个小差异是老工程翻新时最常见的第一道坎。
2.2 源码目录与关键文件职责
增强版源码包一般不会只有一个 .cpp。把目录拉开看,核心文件大致分四类:应用入口、游戏逻辑、界面绘制、资源定义。下面这张表是按常见分层整理的,文件名可能不同,但职责基本对得上。先按职责找文件,比按文件名猜更靠谱。
| 文件/目录 | 职责 | 改玩法时是否常动 |
|---|---|---|
SnakeApp.cpp/.h | 应用初始化、窗口创建、消息循环 | 少动 |
SnakeWnd.cpp/.h | 窗口过程、键盘、定时器、绘图入口 | 中等 |
GameCore.cpp/.h | 蛇身队列、移动、碰撞、生成规则 | 常动 |
GameTypes.h | 坐标、方向、状态枚举、常量 | 常动 |
resource.h | 资源 ID 定义 | 加图片/音效时动 |
res\*.bmp | 蛇身、食物、障碍、背景位图 | 换皮时动 |
res\*.wav | 吃食物、撞墙、道具音效 | 加音效时动 |
*.dsp/.dsw | VC6 工程与工作区配置 | 换编译选项时动 |
我一般先打开GameTypes.h,把MAX_SNAKE、GRID_W、GRID_H、CELL_SIZE、TIMER_INTERVAL这几个常量抄到纸上。它们决定了蛇最长能多长、地图多少格、每格多少像素、定时器多少毫秒。后面调速度、改地图大小,基本都在这里动手。GameCore.cpp里如果出现魔法数字,比如if (len > 200),优先换成常量,不然后面加道具时很容易踩到数组越界。
2.3 首次编译:Debug 与 Release 的差异
VC6 的编译链路不复杂,但默认配置有几个坑:Debug 输出目录和 Release 输出目录可能混在一起,工作区可能同时挂多个工程,资源文件路径可能写成绝对路径。第一次编译建议先清一遍中间文件,再用命令行强制重建,确认到底是工程问题还是源码问题。VC6 自带msdev命令行,可以在项目根目录执行:
REM 先清理中间文件,避免旧的 .obj 干扰 del /s /q Debug\*.obj del /s /q Release\*.obj REM 强制重建 Release 版本,工程名按实际 .dsp 里的目标名改 msdev SnakeEnh.dsw /MAKE "SnakeEnh - Win32 Release" /REBUILD REM 如果只想编译不链接,用 /COMPILE;排查语法错误时更快 msdev SnakeEnh.dsw /MAKE "SnakeEnh - Win32 Release" /COMPILE/MAKE后面的目标名必须和 .dsp 里的配置名完全一致,少一个空格都会报“找不到目标”。/REBUILD会先 Clean 再 Build,适合第一次接手或改过工程配置之后。/COMPILE只编译不链接,排查头文件缺失、宏定义冲突时很省时间。命令行编译的好处是输出日志完整,哪一行报错、缺哪个头文件一目了然,比在 IDE 里翻 Build 窗口快。
Release 和 Debug 的差异也要先确认。Debug 版带调试符号,数组越界不一定立刻崩,但变量未初始化会暴露得更明显;Release 版会做优化,未初始化变量可能表现“正常”,反而把问题藏起来。我的习惯是先跑 Release 确认能出窗口、能移动,再回 Debug 开/GZ查越界。VC6 的/GZ会在函数返回时检查栈指针,能抓到一部分栈溢出,但抓不到堆越界,所以蛇身数组的边界判断还是要自己写清楚。
3. 主循环与输入:用定时器把贪吃蛇 c++ vc6 的节奏稳住
3.1 定时器消息与帧步进
贪吃蛇的“动”不是靠死循环,而是靠定时器每隔一段时间推一步。VC6 下最常见的是SetTimer+WM_TIMER:窗口创建时设一个定时器,消息过程里收到WM_TIMER就调用StepGame(),再把窗口标记为需要重绘。定时器周期决定基础速度,增强版一般会把它做成速度等级,吃够一定分数就缩短周期。下面这段是 Win32 风格的骨架:
// 窗口创建时启动定时器,IDT_GAME 是自定义定时器 ID #define IDT_GAME 1 #define TIMER_BASE_MS 140 void StartGameTimer(HWND hWnd, int level) { // 等级越高,周期越短;最低不低于 60ms,避免消息洪水 int interval = TIMER_BASE_MS - (level - 1) * 10; if (interval < 60) interval = 60; SetTimer(hWnd, IDT_GAME, (UINT)interval, NULL); } // 窗口过程里的处理 case WM_TIMER: if (wParam == IDT_GAME) { // 只有运行态才推进一步,暂停和结束态不推进 if (g_gameState == GS_RUNNING) { StepGame(); InvalidateRect(hWnd, NULL, FALSE); } return 0; } break;SetTimer的第三个参数是毫秒,140对应大约每秒 7 步,适合新手;60对应每秒 16 步左右,已经偏快。StepGame()里只做逻辑:算新头、判碰撞、吃食物、更新分数,不在这里做Sleep或重绘。InvalidateRect只是标记脏区域,真正绘图在WM_PAINT里做,这样消息循环不会被绘图卡住。注意WM_TIMER是低精度消息,系统忙的时候会合并,不能拿它当毫秒计时器;如果要做“限时道具倒计时”,用GetTickCount()记开始时间,在StepGame()里算差值,别数定时器次数。
3.2 键盘输入与方向缓冲
方向键处理最容易翻车的地方是“快速连按导致反向”。比如蛇正在向右走,玩家极快地按了上、左,如果每次WM_KEYDOWN都直接改当前方向,蛇可能在一步之内收到“左”,直接撞到自己脖子。稳妥做法是维护一个长度为 2 的方向队列:按键只入队,StepGame()执行时从队头取一个合法方向,取完再出队。合法性判断只看“不能和当前方向相反”,不限制其他方向。
// 方向定义:0 上,1 右,2 下,3 左 enum Dir { DIR_UP = 0, DIR_RIGHT, DIR_DOWN, DIR_LEFT }; static Dir g_dirQueue[2]; static int g_dirCount = 0; static Dir g_curDir = DIR_RIGHT; // 键盘消息里只入队,不直接改 g_curDir void PushDir(Dir d) { if (g_dirCount >= 2) return; // 队列尾部方向不能和当前方向相反 Dir last = (g_dirCount == 0) ? g_curDir : g_dirQueue[g_dirCount - 1]; if ((last + 2) % 4 == d) return; g_dirQueue[g_dirCount++] = d; } // 每一步从队列取一个方向 void ConsumeDir() { if (g_dirCount > 0) { g_curDir = g_dirQueue[0]; g_dirQueue[0] = g_dirQueue[1]; g_dirCount--; } }PushDir里的(last + 2) % 4 == d就是反向判断:上(0) 的反向是下(2),右(1) 的反向是左(3)。队列长度设为 2,是为了让“上、左”这种连续转弯能在一两步内生效,又不至于积压太多指令。ConsumeDir在StepGame()开头调用,保证每一步只处理一个方向。VC6 的WM_KEYDOWN里记得判断wParam是VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT,不要用GetKeyState轮询,轮询在窗口失去焦点时会漏键。
3.3 状态机与暂停/重开
增强版多了暂停、重开、游戏结束,状态机必须独立出来。常见状态有GS_READY、GS_RUNNING、GS_PAUSED、GS_OVER。定时器只在GS_RUNNING时推进逻辑;暂停时保留画面,结束时不杀定时器但不再步进。这样按空格暂停、按 R 重开都能在一个地方处理。下面是一个最小状态切换:
enum GameState { GS_READY, GS_RUNNING, GS_PAUSED, GS_OVER }; static GameState g_gameState = GS_READY; void TogglePause(HWND hWnd) { if (g_gameState == GS_RUNNING) { g_gameState = GS_PAUSED; KillTimer(hWnd, IDT_GAME); // 暂停时停掉定时器,省消息 } else if (g_gameState == GS_PAUSED) { g_gameState = GS_RUNNING; StartGameTimer(hWnd, GetSpeedLevel()); } } void RestartGame(HWND hWnd) { KillTimer(hWnd, IDT_GAME); InitGameCore(); // 重置蛇身、分数、地图 g_gameState = GS_RUNNING; StartGameTimer(hWnd, 1); InvalidateRect(hWnd, NULL, TRUE); }KillTimer后要保证WM_TIMER里不再有残留逻辑,所以StepGame()入口再判一次状态。RestartGame里先杀定时器再重置,避免重置过程中定时器又进来一步。GetSpeedLevel()根据分数返回等级,InitGameCore()负责把蛇身队列、食物、障碍全部恢复到初始值。状态机的好处是加“倒计时开始”时不用改按键处理,只需要在GS_READY里画倒计时、在GS_RUNNING里推进逻辑。老工程里如果状态用bool bPaused和bool bOver两个变量拼,越加功能越乱,尽早换成枚举。
4. 蛇身、碰撞与增强玩法:数组队列、生成规则和四个边界条件
4.1 蛇身队列与坐标映射
贪吃蛇的蛇身用数组队列最稳:固定长度MAX_SNAKE,头索引和尾索引循环移动。每走一步,新头写入队头,如果没吃到食物就把队尾前移一位,相当于删除尾巴;吃到食物就不动队尾,长度加一。坐标用格子坐标,不直接存像素,绘图时再乘CELL_SIZE。这样改地图大小、改格子像素都不影响逻辑。下面是一个可用的队列骨架:
#define MAX_SNAKE 256 #define GRID_W 32 #define GRID_H 24 #define CELL_SIZE 16 struct SnakeNode { int x; int y; }; struct Snake { SnakeNode body[MAX_SNAKE]; int head; // 队头索引 int tail; // 队尾索引 int length; }; static Snake g_snake; // 取队头,即蛇头 SnakeNode GetHead(const Snake* s) { return s->body[s->head]; } // 推进一步:newHead 是计算好的新蛇头 bool PushHead(Snake* s, SnakeNode newHead, bool eat) { if (s->length >= MAX_SNAKE) return false; int next = (s->head + 1) % MAX_SNAKE; s->body[next] = newHead; s->head = next; s->length++; if (!eat) { // 没吃到食物,尾巴前移,长度不变 s->tail = (s->tail + 1) % MAX_SNAKE; s->length--; } return true; }MAX_SNAKE取 256 是够用的,32×24 的地图最多 768 格,但蛇一般不会占满;如果要做“填满地图”的玩法,把它改成GRID_W * GRID_H。head和tail都是数组下标,不是节点指针,复制Snake结构时不会出现指针悬空。PushHead先写新头再判断是否删尾,顺序不能反:如果先删尾再写头,蛇长度会短暂少一,碰撞检测可能误判。GetHead只读,不修改队列,绘图和碰撞检测都用它取当前蛇头。
4.2 食物、障碍和道具的生成规则
增强版加了障碍和道具,生成规则要比“随机一个空格”多一层:不能生成在蛇身上,不能生成在已有障碍上,不能生成在蛇头正前方一格,否则玩家一转弯就撞。常见做法是先收集所有空格到数组,再随机选一个;空格少的时候比“随机重试”稳定,不会因为地图快满而卡死。下面这段是通用生成:
struct Point { int x; int y; }; static Point g_food; static Point g_obstacles[64]; static int g_obstacleCount = 0; bool IsOccupied(int x, int y) { // 判蛇身 for (int i = 0; i < g_snake.length; ++i) { int idx = (g_snake.tail + i) % MAX_SNAKE; if (g_snake.body[idx].x == x && g_snake.body[idx].y == y) return true; } // 判障碍 for (int i = 0; i < g_obstacleCount; ++i) { if (g_obstacles[i].x == x && g_obstacles[i].y == y) return true; } return false; } bool SpawnRandom(Point* out) { Point empty[GRID_W * GRID_H]; int count = 0; for (int y = 0; y < GRID_H; ++y) for (int x = 0; x < GRID_W; ++x) if (!IsOccupied(x, y)) { empty[count].x = x; empty[count].y = y; count++; } if (count == 0) return false; *out = empty[rand() % count]; return true; }IsOccupied遍历蛇身时从tail开始按长度走,不能直接遍历整个body数组,因为数组里可能有上一局残留的旧节点。SpawnRandom用栈上数组empty收集空格,32×24 最多 768 个Point,栈空间足够;如果地图扩大到 100×100,改成静态数组或堆分配。rand()在 VC6 里需要先srand((unsigned)time(NULL)),否则每次启动生成序列一样,调试时反而方便复现,发布前再换种子。
4.3 碰撞检测的四个边界条件
碰撞检测顺序很关键:先算新头,再判墙、判自身、判障碍,最后判食物。顺序错了会出现“吃到食物同时撞墙还算得分”的怪现象。墙的判定看新头坐标是否越界;自身判定要看“新头是否等于当前尾巴”,因为尾巴这一步会让开,不能一上来就把尾巴算进去;障碍判定直接查障碍数组;食物判定相等就增长。下面这段把四个条件串起来:
enum CollisionResult { CR_NONE, CR_WALL, CR_SELF, CR_OBSTACLE, CR_FOOD }; CollisionResult CheckCollision(SnakeNode newHead) { // 1. 撞墙 if (newHead.x < 0 || newHead.x >= GRID_W || newHead.y < 0 || newHead.y >= GRID_H) return CR_WALL; // 2. 撞自身:跳过当前尾巴,因为没吃到食物时尾巴会前移 for (int i = 0; i < g_snake.length; ++i) { int idx = (g_snake.tail + i) % MAX_SNAKE; if (i == 0) continue; // 尾巴让位,暂不判 if (g_snake.body[idx].x == newHead.x && g_snake.body[idx].y == newHead.y) return CR_SELF; } // 3. 撞障碍 for (int i = 0; i < g_obstacleCount; ++i) if (g_obstacles[i].x == newHead.x && g_obstacles[i].y == newHead.y) return CR_OBSTACLE; // 4. 吃食物 if (newHead.x == g_food.x && newHead.y == g_food.y) return CR_FOOD; return CR_NONE; }i == 0跳过的是当前尾巴,不是蛇头;蛇头在队头,新头还没入队,所以遍历里不会包含新头。如果蛇长度只有 1,尾巴就是头,跳过尾巴后没有自身可撞,逻辑正确。障碍判定放在食物前面,避免食物和障碍生成在同一格时先判食物。实际工程里可以把CheckCollision的返回值直接映射到状态机:CR_WALL、CR_SELF、CR_OBSTACLE进GS_OVER,CR_FOOD加分并重新生成食物。
4.4 增强功能的接入点
增强版的功能不要直接塞进StepGame()的 if 里,留三个钩子更稳:OnBeforeMove处理速度buff和无敌状态,OnAfterMove处理吃食物后的连锁效果,OnGameOver处理最高分存档。下面是一个轻量扩展结构,VC6 下用函数指针数组或switch都行,别引入太重的框架:
struct GameContext { int score; int speedLevel; bool shield; // 护盾道具 bool throughWall; // 穿墙道具 DWORD itemStart; // 道具开始时间,配合 GetTickCount }; static GameContext g_ctx; void OnBeforeMove(GameContext* ctx) { // 道具过期检查,用时间差而不是数定时器次数 if (ctx->shield && GetTickCount() - ctx->itemStart > 5000) ctx->shield = false; } void OnAfterMove(GameContext* ctx, CollisionResult cr) { if (cr == CR_FOOD) { ctx->score += 10; // 每 50 分升一级,等级上限 8 if (ctx->score % 50 == 0 && ctx->speedLevel < 8) ctx->speedLevel++; } }GameContext把分数、速度、道具状态集中在一起,重开时memset或逐字段重置即可。GetTickCount()返回毫秒,5 秒护盾写成5000,不要写成5。OnBeforeMove在StepGame()算新头之前调用,OnAfterMove在碰撞结果确定之后调用。穿墙道具不要在CheckCollision里改墙判定,而是在算出新头后做坐标回绕,这样墙的碰撞逻辑保持单一,调试时不容易混。
5. 避坑与排查:VC6 编译、字符集和定时器精度的五条血泪经验
5.1 现象:编译报找不到 afxwin.h
现象很直接:命令行或 IDE 编译时弹fatal error C1083: Cannot open include file: 'afxwin.h': No such file or directory。原因通常不是源码问题,而是 VC6 安装时没有勾选 MFC 组件,或者Tools -> Options -> Directories里的 Include 路径被改过。另一个可能是工程是 MFC 工程,但当前环境只有 Win32 头文件。解决顺序:先看工程是否需要 MFC,如果只是贪吃蛇逻辑,完全可以改成 Win32 工程;如果确实要用 MFC,检查 VC6 安装目录下VC98\MFC\Include是否存在,再把路径加回 Include 列表。命令行编译时可以用/I临时指定:
REM 临时补充 MFC 头文件路径,路径按本机 VC6 安装位置调整 msdev SnakeEnh.dsw /MAKE "SnakeEnh - Win32 Release" /REBUILD /I "C:\VC6\VC98\MFC\Include"/I只影响本次编译,不改工程配置。如果加了路径还报错,检查是否混用了不同版本的 MFC 库,VC6 工程不要链接新版本库。血泪经验是:老工程报头文件缺失,先确认工程类型,再确认安装组件,最后才怀疑源码。
5.2 现象:Debug 崩、Release 正常
现象是 Release 能玩,Debug 一吃食物就崩,或者蛇走到边界时弹内存错误。原因多半是数组越界或未初始化变量:Release 优化后旧内存刚好是 0,Debug 的堆填充是0xCD,一读就露馅。解决方法是先在 Debug 下把所有结构体初始化,再开 VC6 的/GZ编译选项。蛇身队列的head、tail、length必须在InitGameCore里显式赋值,不能依赖全局零初始化。
void InitGameCore() { memset(&g_snake, 0, sizeof(g_snake)); g_snake.head = 0; g_snake.tail = 0; g_snake.length = 3; // 初始蛇身放在地图中间,方向向右 g_snake.body[0].x = 10; g_snake.body[0].y = 12; g_snake.body[1].x = 9; g_snake.body[1].y = 12; g_snake.body[2].x = 8; g_snake.body[2].y = 12; g_obstacleCount = 0; g_ctx.score = 0; g_ctx.speedLevel = 1; g_ctx.shield = false; g_ctx.throughWall = false; }memset后再逐个赋有效值,比只写部分字段安全。length = 3时,head = 0、tail = 0的队列语义要确认:如果head指向蛇头,tail指向蛇尾,那么body[0]是头,body[1]、body[2]是身体,遍历时从tail开始按长度走。不同工程可能用head/tail反过来,改之前先看GetHead和绘图循环怎么取值,别照抄。
5.3 现象:中文路径或资源加载失败
现象是图片不显示、字符串乱码,或者LoadBitmap返回NULL。VC6 默认多字节字符集,工程里如果混用TCHAR、CString和char[],很容易在中文路径上翻车。解决原则:资源 ID 用MAKEINTRESOURCE,路径尽量用相对路径,字符串统一用TCHAR或统一用char,不要一半一半。MFC 工程里CString在 Unicode 配置下是宽字符,VC6 默认不是 Unicode,但有人从新工程拷代码会带_T()宏,混用后LoadString参数类型不匹配。
// Win32 下加载位图,资源 ID 来自 resource.h HBITMAP hBmp = (HBITMAP)LoadImage( GetModuleHandle(NULL), MAKEINTRESOURCE(IDB_SNAKE_BG), IMAGE_BITMAP, 0, 0, LR_CREATEDIBSECTION); if (hBmp == NULL) { // 不要只弹“加载失败”,把 GetLastError 打出来 DWORD err = GetLastError(); TCHAR msg[64]; wsprintf(msg, TEXT("LoadImage failed: %lu"), err); MessageBox(NULL, msg, TEXT("resource"), MB_OK); }MAKEINTRESOURCE把整数资源 ID 转成指针,不能直接传IDB_SNAKE_BG的整数值。LR_CREATEDIBSECTION让位图有独立像素区,后面双缓冲绘图取像素更方便。GetLastError在LoadImage失败后立刻取,别中间再调用其他 API,否则错误码会被覆盖。资源文件要确认已经加入工程,资源视图里能看到 ID,resource.h里的宏和 .rc 文件一致。
5.4 现象:越玩越慢、定时器漂移
现象是蛇一开始速度正常,玩几分钟后明显变慢,或者按暂停再恢复后节奏不对。原因通常是WM_TIMER精度低、消息会合并,加上StepGame()里做了重活(比如每步都重新加载位图、每步都Sleep)。解决思路是逻辑与绘图分离:定时器只推逻辑,绘图在WM_PAINT里做;需要精确计时的道具用GetTickCount()算差值;不要在WM_TIMER里调用Sleep或MessageBox。
// 用时间片控制逻辑步进,避免 WM_TIMER 合并导致一次跳多格 static DWORD g_lastStep = 0; void OnGameTimer() { DWORD now = GetTickCount(); int interval = GetCurrentInterval(); // 根据等级算出的毫秒数 if (now - g_lastStep < (DWORD)interval) return; g_lastStep = now; StepGame(); InvalidateRect(g_hWnd, NULL, FALSE); }g_lastStep在重开时清零,避免上一局的时间差影响新局。GetCurrentInterval()返回当前等级对应的毫秒数,等级变化后下一次步进自然生效。这个写法不能让WM_TIMER变精确,但能防止“定时器消息堆积后一次走好几步”。如果要做固定帧率,VC6 下可以用timeSetEvent多媒体定时器,但那个回调在独立线程,操作 UI 要加锁,贪吃蛇这种小游戏没必要上。
5.5 现象:资源 ID 冲突、图片显示错位
现象是换了新皮肤后,食物显示成蛇身,或者背景图跑到窗口左上角。原因可能是resource.h里手工改了 ID 导致冲突,或者BitBlt的源坐标、目标坐标写反。解决:资源 ID 统一由资源编辑器生成,不要手改;绘图时先用SetStretchBltMode或确认CELL_SIZE和位图尺寸一致;双缓冲的兼容 DC 要选对位图。
// 双缓冲初始化,避免每帧创建销毁 static HDC g_memDC = NULL; static HBITMAP g_memBmp = NULL; void InitBackBuffer(HWND hWnd, int w, int h) { HDC hdc = GetDC(hWnd); g_memDC = CreateCompatibleDC(hdc); g_memBmp = CreateCompatibleBitmap(hdc, w, h); SelectObject(g_memDC, g_memBmp); ReleaseDC(hWnd, hdc); } void FlushBackBuffer(HWND hWnd) { HDC hdc = GetDC(hWnd); BitBlt(hdc, 0, 0, GRID_W * CELL_SIZE, GRID_H * CELL_SIZE, g_memDC, 0, 0, SRCCOPY); ReleaseDC(hWnd, hdc); }CreateCompatibleBitmap的宽高要和窗口客户区一致,不能只写GRID_W不乘CELL_SIZE。SelectObject把位图选进内存 DC 后,所有绘图都画在位图上,最后BitBlt一次性刷到窗口。资源 ID 冲突时,VC6 资源视图里会显示重复 ID,编译不一定报错,但运行时会取错资源,换皮后先核对resource.h里的宏值。
6. 从能跑到敢改:双缓冲绘图、参数标定与回归验证
能跑起来只是第一步,敢改才说明看懂了。贪吃蛇绘图最容易翻车的地方是闪烁:每帧先InvalidateRect再在WM_PAINT里直接画,蛇一快就闪。我一般用内存 DC 双缓冲,把整帧画到兼容位图,再一次BitBlt到窗口。上面的InitBackBuffer在窗口创建时调用一次,WM_PAINT里先清内存 DC 背景,再画食物、障碍、蛇身,最后FlushBackBuffer。注意WM_ERASEBKGND要返回TRUE,阻止系统再刷一遍背景,否则双缓冲也压不住闪烁。
参数标定别凭感觉。下面这张表是我改速度时常用的起点,定时器周期和每步格数要一起看:周期太短、步长又大,玩家反应不过来;周期太长,蛇像幻灯片。
| 速度等级 | 定时器周期(ms) | 每步格数 | 适用场景 |
|---|---|---|---|
| 1 | 160 | 1 | 新手熟悉地图 |
| 2 | 140 | 1 | 默认开局 |
| 3 | 120 | 1 | 有障碍物 |
| 4 | 100 | 1 | 需要预判 |
| 5 | 80 | 1 | 高分段 |
| 6 | 70 | 1 | 挑战模式 |
验证方法不用太复杂,固定三组用例就够:第一组把蛇逼到四面墙,确认撞墙进GS_OVER;第二组让蛇贴着尾巴转弯,确认自身碰撞不误判;第三组吃食物到升级线,确认定时器周期真的变了、分数没跳变。每次改StepGame或CheckCollision,先把这三组跑一遍,再谈加道具。固定srand种子能复现食物序列,录屏对比前后两版,比肉眼盯快得多。从那以后我每次改移动步长和定时器周期,都先把这三个回归用例跑一遍,再谈加玩法。希望帮到你。
本文还有配套的精品资源,点击获取