VC6.0 C++贪吃蛇增强工程:Win32定时器与环形队列实战
简介:「贪吃蛇游戏增强VC6.0开发」是一套面向C++与游戏开发初学者的VC6.0实战项目,围绕经典贪吃蛇玩法讲解面向对象设计、链表数据结构与Windows图形界面开发。压缩包共58个文件、约8.98MB,包含cpp与h源代码、VC6.0的dsp/dsw工程文件、exe可执行程序、bmp位图素材,以及obj、sbr、pdb等编译调试中间文件,便于直接运行、阅读源码和跟踪调试。项目采用蛇与链表节点分离的双类设计,涉及键盘消息处理、MFC绘图、定时器控制速度、游戏状态切换与异常退出等环节,可帮助读者理解封装思想、链表增删遍历及MFC消息循环。目前已有180人学习,适合希望从源码层面掌握C++小游戏开发流程、巩固数据结构并积累VC6.0调试经验的学习者。
1. 从一份 VC6.0 贪吃蛇增强工程说起:它到底能帮你省掉哪些重复活
在只装 VC6.0 的老机房机器上,想跑一个能演示、能改参数的贪吃蛇 C++ 增强版,最省事的起点不是重写,而是拆一份能编译的工程源码。它把 Win32 窗口、GDI 绘制、定时器节拍和蛇体数组串在一起,适合课程设计演示、老环境复现,也适合刚学完 C++ 语法想摸消息循环的人。
这类工程的价值在边界清楚:食物生成、撞墙判定、穿墙规则、加速档位、分数落盘都能单独拎出来改。直接双击 exe 只能看到蛇在跑;把 .dsw 拉进 VC6.0 断点跟一遍,才知道哪一步该改、哪一处容易翻车。
适合手头只有 VC6.0、要交一份能讲清原理的 C++ 小项目、又不想从空白 Win32 工程搭架子的人。不适合想直接套现代框架做商业游戏的人。
2. 把 .dsw 拉进 VC6.0:工程结构、依赖与第一次编译通过
老工程最容易死在“能打开但编不过”。先别改代码,先认清工程类型和入口。VC6.0 的工作区 .dsw 管多个 .dsp,每个 .dsp 才决定编译成 Win32 Console 还是 Win32 Application。贪吃蛇增强版通常是一个 Win32 Application,入口是 WinMain,窗口过程是 WndProc。如果误建成 Console,链接器会去找 main 而不是 WinMain,报 unresolved external symbol _WinMain@16 或 _main。先看 Project Settings -> General -> Microsoft Foundation Classes,选 Not Using MFC;如果选 Use MFC,会引入 MFC 依赖,老机器缺库就麻烦。
2.1 工程文件角色与常见误用
VC6.0 时代的工作区文件、项目文件和源码文件分工很明确,摸清之后排错会快很多。下面这张表按扩展名说明角色和常见误用,不涉及具体文件数量。
| 扩展名 | 角色 | 常见误用 |
|---|---|---|
| .dsw | 工作区,管理一个或多个项目 | 把 .dsw 当源码打开,或只拷贝 .dsp 导致路径丢失 |
| .dsp | 项目配置,记录源文件、编译选项、链接选项 | 直接文本编辑 .dsp,改错路径后 FileView 里出现红叉 |
| .cpp | 游戏逻辑、窗口过程、入口函数 | 把 .h 加进 Source Files 参与编译,报重复定义 |
| .h | 结构体、函数声明、常量 | 在头文件里定义全局变量,多个 .cpp 包含后链接冲突 |
| .rc | 图标、菜单、对话框资源 | 资源 ID 与代码里引用不一致,窗口图标不显示 |
打开顺序是 File -> Open Workspace,选 .dsw;如果手里只有 .dsp,就 File -> Open Project。打开后看 FileView,Source Files 里应该有包含 WinMain 和 WndProc 的 .cpp,Header Files 里放结构体和常量,Resource Files 里放 .rc。看到文件前面有红叉,通常是 .dsp 里记录的相对路径和你当前目录不一致,不要急着删文件,先把整个工程目录拷到纯英文短路径下再打开。
提示:VC6.0 对中文路径和空格路径兼容性差,工程目录尽量放在纯英文短路径下。
2.2 VC6.0 编译配置的四个关键开关
第一个开关是 General 里的项目类型。Win32 Application 对应 WinMain,Win32 Console Application 对应 main。贪吃蛇增强版如果有窗口、有消息循环,就选 Win32 Application。第二个开关是 C/C++ -> Preprocessor 里的 _WIN32 和 _DEBUG/_NDEBUG。Debug 配置带 _DEBUG,Release 配置带 NDEBUG,条件编译里的断言和日志要靠它切换。第三个开关是 C/C++ -> Code Generation 里的运行库。VC6.0 默认 Debug 用 Debug Multithreaded DLL,Release 用 Multithreaded DLL,生成的 exe 依赖 msvcrtd.dll 或 msvcrt.dll。老机器缺运行库时,把 Debug 和 Release 都改成 Multithreaded,静态链接运行库,exe 会大一点,但少一个“换机就报缺少 DLL”的坑。第四个开关是 Link 里的 /subsystem:windows 和入口点。Win32 Application 默认入口是 WinMainCRTStartup,不要手痒改成 mainCRTStartup,否则链接器又会去找 main。
还有一个字符集问题。VC6.0 没有现在常见的 Unicode 字符集选项,默认 ANSI,字符串用 char,Win32 API 用 ...A 版本。如果代码里混入 TCHAR、LPCWSTR,容易出现 cannot convert parameter 1 from 'char *' to 'const unsigned short *'。稳妥做法是统一用 char 和 ANSI API,资源里的字符串也用 ANSI。别在 VC6.0 工程里抄现代 Win32 教程的宽字符代码,抄进来就是一堆类型不匹配。
2.3 从备份到可执行文件的操作顺序
先复制整个工程目录做备份,别在原目录上直接改。打开 .dsw,Build -> Set Active Configuration 选 Debug,然后 Build -> Rebuild All。第一次编译不要只按 F7,Rebuild All 会把旧 obj 清掉,避免“改了头文件但没重新编译”的假象。如果报 fatal error C1083 找不到头文件,去 Project Settings -> C/C++ -> Preprocessor -> Additional include directories 加相对路径,路径之间用分号隔开。如果报 unresolved external symbol,先看符号名:_WinMain@16 是入口问题,_main 是项目类型反了,其他符号多半是漏加 .cpp 或漏加库。
下面是一段 VC6.0 兼容的 Win32 入口骨架,用来对照工程里的 WinMain 和 WndProc 是否完整。代码里保留了定时器创建、消息处理和 DC 释放,窗口类注册和 CreateWindow 省略,实际工程里要补全。
// main.cpp - VC6.0 兼容的 Win32 入口骨架 #include <windows.h> #include "game.h" static Game g_game; LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: g_game.Init(hWnd); // 初始化蛇、食物、障碍和分数 SetTimer(hWnd, 1, 120, NULL); // 1 是定时器 ID,120 是毫秒间隔 return 0; case WM_TIMER: if (wParam == 1) { // 只处理游戏定时器,避免别的定时器误触发 HDC hdc = GetDC(hWnd); g_game.Update(); // 先算逻辑,再画画面 g_game.Draw(hdc); ReleaseDC(hWnd, hdc); // GetDC 和 ReleaseDC 必须成对 } return 0; case WM_DESTROY: KillTimer(hWnd, 1); PostQuitMessage(0); return 0; default: return DefWindowProc(hWnd, msg, wParam, lParam); } } int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR cmd, int nShow) { // 这里省略窗口类注册和 CreateWindow,保留入口签名供工程对照 // 重点检查:入口是 WinMain,不是 main;项目类型是 Win32 Application return 0; }这段骨架的逻辑是:WM_CREATE 里只做一次初始化并启动定时器,WM_TIMER 里先 Update 再 Draw,WM_DESTROY 里杀掉定时器并退出消息循环。参数上,SetTimer 的第二个参数是定时器 ID,第三个参数是毫秒间隔,第四个参数为 NULL 表示由系统调用窗口过程。GetDC 拿到的 HDC 用完必须 ReleaseDC,否则 GDI 资源泄漏,跑久了会出现绘制异常。WinMain 的 nShow 要传给 ShowWindow,hPrev 在 Win32 下通常为 NULL,cmd 是命令行参数,不用可以忽略。第一次编译通过后,先别加新玩法,跑一遍看蛇能不能动、能不能吃食物,再往下改。
3. 蛇体怎么存、怎么走:数组队列、方向缓冲与定时器节拍
贪吃蛇的难点不是画方块,而是“移动”和“增长”的时序。很多增强版翻车在蛇头先走、尾巴先缩、食物先生成,顺序一错就出现多一节错位或穿墙后头消失。用固定数组做环形队列,逻辑清楚,VC6.0 下也没有 STL 依赖负担。下面按蛇体结构、方向输入、帧节拍三块拆。
3.1 固定数组还是链表:蛇体结构选型
固定数组的好处是内存连续、下标计算直接、VC6.0 的调试器里展开看得很清楚。链表每个节点 new/delete,在 Debug 下慢,还容易在增长和删除尾巴时漏释放。贪吃蛇的蛇身长度上限由网格数决定,20x20 的格子最多 400 节,给 256 的数组通常够用。环形队列不用整体搬移,蛇头进、蛇尾出,只改两个下标。
#define MAX_SNAKE 256 struct Point { int x; int y; }; struct Snake { Point body[MAX_SNAKE]; // body[0] 是蛇头,逻辑上按 head 往前取 int head; // 环形队列头下标 int tail; // 环形队列尾下标 int length; // 当前节数 int dir; // 0 上,1 右,2 下,3 左 int nextDir; // 下一拍生效的方向 Point Get(int i) const { int idx = (head - i + MAX_SNAKE) % MAX_SNAKE; return body[idx]; } };这段结构的核心是Get(i):i=0 取蛇头,i=1 取第二节,依次往前。head指向最新蛇头,tail指向蛇尾。正常移动时,新头写入(head+1)%MAX_SNAKE,head前移,tail前移;吃到食物时只前移head,tail不动,长度加一。参数MAX_SNAKE不是越大越好,太大浪费栈空间,太小会在长蛇时覆盖身体。常见做法是按网格总数的一半到三分之二给上限,20x20 给 256 足够。VC6.0 不支持enum class,方向用 int 或普通 enum,别用现代 C++ 的强类型枚举。
3.2 方向输入与禁止反向的缓冲逻辑
键盘输入不能直接改dir。WM_KEYDOWN 可能连续来,玩家快速按上、左,如果第一下已经把dir改成上,第二下左就不算反向,蛇会在同一拍里拐两次,甚至出现原地掉头。稳妥做法是用nextDir缓冲,在每一拍 Step 开始时才把nextDir赋给dir。禁止反向用(dir + 2) % 4判断,方向编码固定为 0 上、1 右、2 下、3 左,反向就是加 2 取模。
void Snake::PushDir(int newDir) { if (newDir < 0 || newDir > 3) return; // 过滤非法键值 if ((dir + 2) % 4 == newDir) return; // 禁止 180 度掉头 nextDir = newDir; // 只缓存,下一拍生效 } void Snake::Step() { dir = nextDir; // 一拍只认一个方向 Point nh = body[head]; if (dir == 0) nh.y--; else if (dir == 1) nh.x++; else if (dir == 2) nh.y++; else nh.x--; // 碰撞、吃食物、入队、出队按固定顺序在 Update 里处理 // 这里只负责算出新头坐标,避免把规则揉进输入函数 }PushDir里先判非法值,再判反向,最后写nextDir。Step里先同步方向,再算新头。新头坐标算出来后,Update 的顺序建议固定为:判穿墙或撞墙、判撞自身、判吃食物、入队新头、按是否增长决定尾巴是否前移。撞自身时要注意尾巴格子:如果本拍不增长,尾巴会让出来,新头等于尾巴不算撞;如果本拍增长,尾巴不动,新头等于尾巴就算撞。这个细节不处理,长蛇贴尾移动时会误判死亡。参数dir和nextDir初始值要一致,否则第一拍可能往 unexpected 的方向走。
3.3 SetTimer 还是主循环 Sleep:帧节拍控制
VC6.0 下做小游戏,SetTimer 比主循环 Sleep 稳。Sleep 会阻塞消息循环,拖动窗口、点关闭按钮都会卡;SetTimer 把节拍交给消息队列,窗口过程在空闲时处理 WM_TIMER。贪吃蛇不需要 60 帧,120ms 一拍约 8 帧/秒,足够看清移动。加速时不要直接改一个全局 speed 变量就完事,要先 KillTimer 再 SetTimer,而且定时器 ID 要统一,否则多个定时器叠加,蛇会突然跳格。
// 启动:120ms 一拍 SetTimer(hWnd, IDT_GAME, 120, NULL); // 加速:先杀掉旧定时器,再设新间隔 KillTimer(hWnd, IDT_GAME); SetTimer(hWnd, IDT_GAME, 80, NULL);参数上,IDT_GAME自定义为 1,避免和资源 ID 冲突;间隔不要小于 10ms,否则 WM_TIMER 消息堆积,实际节拍反而不可控。窗口最小化时 KillTimer,恢复时重设,防止后台继续跑分。WM_TIMER 里要判断wParam == IDT_GAME,别把其他定时器的消息也当成游戏更新。VC6.0 的 WM_TIMER 精度不高,但贪吃蛇的移动是离散格子,不追求毫秒级精确,稳定比精确更重要。
4. 增强玩法的落地:穿墙、障碍、加速与分数持久化
增强版和基础版差别就在规则。规则加得越多,越需要把“可通行”抽成函数。否则穿墙、障碍、食物生成各写一套判断,改一个参数就漏。下面按穿墙、障碍、速度分数三块拆,每块都给可抄的代码和参数说明。
4.1 穿墙与撞墙的边界判定
穿墙最容易踩的坑是负数取模。C++ 里负数%的结果在 VC6.0 下可能为负,-1 % 20得到 -1,不是 19。所以穿墙不要写p.x = (p.x + gw) % gw就完事,用条件分支最稳。撞墙模式则直接判断坐标是否越界。两种模式用一个开关切换,别在绘制函数里再写一遍边界。
// 网格宽 20、高 20,坐标范围 0..19 bool WrapPoint(Point& p, int gw, int gh) { if (p.x < 0) p.x = gw - 1; else if (p.x >= gw) p.x = 0; if (p.y < 0) p.y = gh - 1; else if (p.y >= gh) p.y = 0; return true; // 穿墙模式返回 true } bool HitWall(Point p, int gw, int gh) { return p.x < 0 || p.x >= gw || p.y < 0 || p.y >= gh; }WrapPoint接收引用,直接改新头坐标;HitWall接收值,只做判断。参数gw、gh从客户区大小除以格子大小算出来,不要写死。比如客户区 400x400、格子 20,就是 20x20。穿墙后要重绘整格,先填背景再画蛇,否则旧蛇头残留。坐标方向也要统一:MM_TEXT 原点在左上,x 向右增,y 向下增,dir=0是上就是 y 减一,别在绘制时又翻一次。
4.2 障碍物生成与可通行性校验
障碍物不能随便rand()%20就放。要避开蛇身、食物和其他障碍,还要留出至少两格通道,否则生成出来就是死局。常见做法是写一个IsOccupied,把蛇身和障碍都查一遍,候选点最多重试固定次数,失败就放弃本次生成。随机种子在调试时固定,方便复现问题;正式玩再用GetTickCount()。
bool IsOccupied(const Game& g, Point p) { int i; for (i = 0; i < g.snake.length; ++i) { Point sp = g.snake.Get(i); if (sp.x == p.x && sp.y == p.y) return true; } for (i = 0; i < g.obstacleCount; ++i) { if (g.obstacles[i].x == p.x && g.obstacles[i].y == p.y) return true; } return false; }IsOccupied只查蛇和障碍,食物是否占用可以在生成食物时单独判断。生成障碍时先随机候选点,再调IsOccupied,最多重试 50 次;50 次还找不到就不生成,避免死循环。参数obstacleCount随关卡增加,但不要超过空格总数的一半,否则蛇没有足够活动空间。障碍不要贴着蛇头初始位置,至少留两格缓冲,不然开局第一拍就撞。
4.3 速度档位与分数落盘
速度档位不要散落在各个函数里。封装一个SetSpeed,里面先 KillTimer 再 SetTimer,外部只传毫秒数。分数落盘用 VC6.0 自带的GetPrivateProfileInt和WritePrivateProfileString,ini 文件放 exe 同目录。注意相对路径“snake.ini”依赖当前工作目录,双击 exe 和从 IDE 启动的工作目录可能不同,稳妥做法是用GetModuleFileName拼绝对路径。
// 读配置:ini 与 exe 同目录,workDir 由 GetModuleFileName 拼出来 int speed = GetPrivateProfileInt("game", "speed", 120, iniPath); int wrap = GetPrivateProfileInt("game", "wrap", 1, iniPath); // 写最高分:游戏结束时写一次,不要每吃一个食物写一次 char buf[32]; wsprintf(buf, "%d", g.score); WritePrivateProfileString("game", "highscore", buf, iniPath);参数speed单位是毫秒,越小越快;wrap=1穿墙,wrap=0撞墙。GetPrivateProfileInt的第三个参数是默认值,ini 不存在或键缺失时返回它,避免读不到配置就崩。wsprintf的缓冲区给 32 字节够存 int 分数,但别用sprintf不设上限。分数落盘放在游戏结束或退出时,频繁写 ini 会拖慢节奏,也容易在异常退出时留下半个文件。
5. 避坑排查:VC6.0 下贪吃蛇最容易翻车的五处
老工程报错不可怕,怕的是一通乱改。下面五条按“现象→原因→解决”记录,都是这类源码里高频出现的问题。每条先别急着改逻辑,先定位是工程配置、消息节拍还是数组下标。血泪经验是:先备份,再 Rebuild All,再看符号名,最后才动代码。
5.1 现象:链接报 unresolved external symbol _WinMain@16
现象是编译能过,链接报unresolved external symbol _WinMain@16,或者反过来报_main。原因是项目类型和入口函数不匹配:项目是 Win32 Console Application,代码却写 WinMain;或者项目是 Win32 Application,代码却写 main。还有一种情况是包含 WinMain 的 .cpp 被排除在项目之外,FileView 里看不到,链接器自然找不到。
解决方法是先看 Project Settings -> General 的项目类型。窗口程序选 Win32 Application,控制台程序选 Win32 Console Application。再看 Link -> Project Options 里是不是/subsystem:windows,不熟悉不要手改入口点。然后确认包含 WinMain 的 .cpp 在 Source Files 里,没有被 Exclude from build。最后检查函数签名:int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int),四个参数一个都不能少。改完 Rebuild All,别只按 F7。
5.2 现象:蛇越玩越快,暂停后还在跑
现象是每次加速后蛇速翻倍,或者按了暂停键蛇还在动。原因通常是定时器 ID 不统一,加速函数里又 SetTimer 了一个新 ID,旧 ID 没 Kill,两个定时器同时触发;或者 WM_TIMER 里没判断 wParam,把别的定时器消息也当成游戏更新。暂停只改了标志位没 KillTimer,也会出现“暂停后还在跑”。
解决方法是统一定时器 ID,所有 SetTimer 都用IDT_GAME,加速前先KillTimer(hWnd, IDT_GAME)。WM_TIMER 里第一行判断if (wParam != IDT_GAME) return;。暂停时 KillTimer,恢复时按当前速度重新 SetTimer,不要靠一个布尔量在 Update 里 return,因为定时器还在跑,只是逻辑没更新,恢复时容易跳拍。检查 WM_CREATE 是否被重复触发,窗口重建时定时器会再建一个。
5.3 现象:吃食物后多一节,但新节位置错乱
现象是蛇头吃到食物,长度加一,但尾巴后面多出一格在屏幕别处,或者蛇身中间出现断点。原因是增长时序错了:先改 length 再入队,或者环形队列的 head/tail 用 length 推算而不是用实际下标。还有一种是把body[i]当成从蛇头开始的顺序,实际 body 是环形存储,必须用Get(i)。
解决方法是固定顺序:算新头、判碰撞、判食物、入队新头、没吃食物才前移 tail。增长时 tail 不动,长度加一;正常移动时 head 和 tail 都前移。调试时把 head、tail、length 打印出来,吃食物前后各看一次,确认 tail 没被误改。数组入队前判断length < MAX_SNAKE,满了就不要再加,否则覆盖蛇头。
5.4 现象:穿墙后蛇头消失或出现在左上角
现象是从左边出去,蛇头没在右边出现,或者直接出现在 (0,0)。原因是负数取模得到负坐标,绘制时被裁掉;或者 Wrap 只改了临时变量,没写回蛇头;或者绘制时用了旧坐标,背景没重绘。客户区坐标和网格坐标混用也会这样,比如网格 x=19 对应像素 380,绘制时忘了乘格子大小。
解决方法是穿墙用条件分支处理负坐标,不用负数取模。Wrap 后立即把新头写回队列,再重绘整格背景。检查绘制函数里x * CELL和y * CELL是否都乘了格子大小。穿墙模式打开时,撞墙判定要跳过,别两个规则同时生效。窗口客户区大小变化时重新算 gw/gh,不要把 20x20 写死。
5.5 现象:Debug 正常,Release 崩溃或蛇身随机
现象是 Debug 配置跑得好好的,Release 一吃食物就崩,或者蛇身位置随机。原因是未初始化局部变量、数组越界写坏相邻成员、字符串缓冲区溢出。Release 优化会暴露未定义行为,Debug 下内存填充刚好没崩,Release 就翻车。VC6.0 的 Release 默认 /O2,未初始化变量可能直接是随机值。
解决方法是打开 /W4 警告,把所有 Point 和计数初始化为 0。入队前判length < MAX_SNAKE,出队前判length > 0。字符串用wsprintf时给足缓冲区,或者用_snprintf并手动置零。Release 也生成调试信息,Project Settings -> C/C++ -> General 里选 Program Database,链接器选 Generate Debug Info,崩溃时还能看调用栈。自定义一个 CHECK 宏,在关键下标处判断范围,比事后猜强。
6. 进阶验证:把增强版改成可调参的课堂 Demo
把参数从代码里挪到 ini,课堂演示时不用重新编译就能改速度、改穿墙开关。ini 放 exe 同目录,内容就两行:speed=120、wrap=1。读取用GetPrivateProfileInt,路径用GetModuleFileName拼绝对路径,避免从 IDE 启动时工作目录不对。改完参数后调用SetSpeed,内部先 KillTimer 再 SetTimer,别直接改变量。
// 读取 snake.ini 中的速度与穿墙开关 int speed = GetPrivateProfileInt("game", "speed", 120, iniPath); int wrap = GetPrivateProfileInt("game", "wrap", 1, iniPath);验证碰撞和分数,最直接的办法是开日志。每拍把蛇头坐标、长度、分数、方向写一行,跑三局就能看出穿墙后坐标有没有回
本文还有配套的精品资源,点击获取