简介:这是一份面向C++初学者与游戏开发入门者的贪吃蛇增强版源码工程,基于VC6.0与MFC实现,适合用来练习面向对象设计、链表数据结构与Windows图形界面编程。工程采用双类设计,蛇体以单链表组织,涵盖键盘事件响应、定时器控制游戏速度、界面绘制与状态机管理、异常退出处理等完整逻辑,可直接编译运行并二次修改。压缩包共58个文件,约8.98MB,包含5个cpp源文件与6个头文件承载核心逻辑,配套dsp、dsw工程文件与rc资源脚本,另有bmp位图、ico图标等素材,以及obj、sbr、pdb等编译调试中间产物,便于对照调试。目前已有178人学习下载。通过阅读与调试该工程,读者可掌握链表节点增删遍历、MFC消息循环与定时器用法,理解游戏从设计、编码到测试的完整流程,是C++课程设计与自学练手的实用参考。
1. 贪吃蛇增强版在 VC6.0 里跑通:一份老 IDE 的实战拆解
现在打开 Visual Studio 2022 写 C++ 的人,可能很难想象还有人守着 VC6.0 不放。但现实是,不少高校的 C++ 课程设计、嵌入式培训、甚至某些产线工控机的配套工具链,依然锁死在 VC6.0 上。你拿一份 VS2022 的工程丢过去,连 .dsw 都打不开,更别提 MFC 版本差异和 C99 语法支持了。这份「贪吃蛇游戏增强版 VC6.0 开发」资源,解决的就是这个断层——它是一套能在 VC6.0 里直接编译运行的完整工程,不是那种只给几个 .cpp 文件的半成品。增强版意味着它比课堂上的黑白控制台版本多了几层东西:双缓冲绘图、难度曲线、道具系统、分数持久化。适合谁?正在做课程设计但不想从零搭框架的人,想拿老 IDE 练手 Windows GDI 编程的人,以及需要一份能跑通的 VC6.0 MFC 工程做二次开发的人。下面我按「拿到工程怎么跑 → 代码怎么组织 → 参数怎么调 → 坑在哪」的顺序拆一遍。
2. 工程结构与编译链路:从 .dsw 到可执行文件
2.1 拿到压缩包后先看什么
解压之后别急着双击 .dsw。先确认目录里有没有这几个关键文件:.dsw(Workspace 文件,VC6.0 的工程入口)、.dsp(Project 文件,记录源文件和编译选项)、StdAfx.h/StdAfx.cpp(预编译头,MFC 工程标配)、res文件夹(存放图标、位图、对话框资源)。如果缺了.dsp,那这份资源大概率是从别的 IDE 迁移过来的,VC6.0 打不开,得手动重建工程。
常见做法是先用文本编辑器打开.dsp,搜一下SOURCE=行,看看它引用了哪些 .cpp 文件,跟实际目录里的文件对不对得上。我见过不少资源包,作者打包时漏掉了某个 .cpp,编译直接报LNK2001: unresolved external symbol,新手会以为是代码问题,其实是文件没给全。
2.2 编译前必须改的三个设置
VC6.0 默认的编译配置跟现代工程有几处冲突,不改的话大概率翻车。
第一处是预编译头。MFC 工程默认走Use Precompiled Header,但有些增强版工程为了兼容纯 Win32 编译,把StdAfx.cpp的预编译头关了,而其他文件还开着,结果就是fatal error C1010: unexpected end of file while looking for precompiled header directive。解决办法:在 Project Settings → C/C++ → Precompiled Headers 里,把StdAfx.cpp设为Create Precompiled Header,其余 .cpp 设为Use Precompiled Header,或者干脆全部选Not using precompiled headers。
第二处是字符集。VC6.0 没有 Unicode 和多字节的显式选项,默认走 MBCS。如果代码里混了_T()宏和LPCTSTR,编译能过,但运行时段错误。检查方法:看WinMain或InitInstance里有没有AfxEnableControlContainer()之类的 MFC 初始化调用,有的话说明是标准 MFC 工程,别乱改字符集。
第三处是链接库。增强版如果用了GdiPlus做抗锯齿绘图,需要在 Project Settings → Link → Object/Library Modules 里加gdiplus.lib,否则报LNK2001: unresolved external symbol _GdipCreateFromHDC@8。
# 如果你在命令行用 nmake 编译(VC6.0 自带 nmake) # 先设置环境变量,路径按实际安装位置改 set PATH=C:\Program Files\Microsoft Visual Studio\VC98\Bin;%PATH% set INCLUDE=C:\Program Files\Microsoft Visual Studio\VC98\Include set LIB=C:\Program Files\Microsoft Visual Studio\VC98\Lib # 进入工程目录,执行 nmake cd SnakeEnhanced nmake /f SnakeEnhanced.mak CFG="SnakeEnhanced - Win32 Release"上面这段是命令行编译的流程。nmake读的是.mak文件,不是.dsp。VC6.0 在Build → Batch Build里可以导出 makefile。参数CFG指定配置名,必须跟.mak里定义的一致,写错了会报NMAKE : fatal error U1073: don't know how to make。一般我建议新手直接用 IDE 编译,命令行方式适合做自动化构建或者批量出包。
2.3 运行时的依赖检查
编译通过不等于能跑。VC6.0 编译出来的 Release 版依赖MFC42.dll、MSVCRT.dll、MSVCP60.dll这几个运行时。在开发机上没问题,因为 VC6.0 安装时已经注册了。但拷到别的机器上,如果没装 VC6.0 运行库,双击就报「无法启动此程序,因为计算机中丢失 MFC42.dll」。
解决办法有两种:一是把编译选项改成Use MFC in a Static Library,在 Project Settings → General → Microsoft Foundation Classes 里选Use MFC in a Static Library,这样 exe 体积会大几百 KB,但不依赖外部 dll。二是把MFC42.dll和MSVCRT.dll一起打包,放到 exe 同目录。我一般选静态链接,省事。
提示:静态链接 MFC 后,如果工程里用了
AfxGetApp()获取全局对象,行为跟动态链接时一致,不用担心兼容性问题。
3. 核心模块拆解:蛇体逻辑、食物生成与双缓冲绘图
3.1 蛇体数据结构和移动算法
增强版跟基础版最大的区别在蛇体的存储方式。基础版通常用一个固定长度数组int snake[100][2],蛇头移动时整体搬移数组,时间复杂度 O(n)。增强版一般改用环形队列或者std::deque,但 VC6.0 对 STL 支持不完整,std::deque在某些补丁版本上会报错,所以这份资源大概率用的是自定义环形缓冲区。
// SnakeCore.h 里常见的蛇体定义 #define MAX_SNAKE_LEN 256 struct SnakeNode { int x; int y; }; class CSnakeBody { public: CSnakeBody(); void Move(int dx, int dy); // 按方向移动一格 void Grow(); // 吃到食物后加长 bool CheckCollision() const; // 检测撞墙或撞自己 void Reset(); // 重新开始 private: SnakeNode m_body[MAX_SNAKE_LEN]; int m_head; // 头指针,指向最新蛇头 int m_tail; // 尾指针,指向最旧蛇尾 int m_length; // 当前长度 int m_capacity; // 最大容量 };这段代码的关键在m_head和m_tail的推进方式。Move函数里,新蛇头位置写入m_body[m_head],然后m_head = (m_head + 1) % m_capacity。如果没吃到食物,m_tail也往前推一格,相当于尾部缩进。Grow只推m_head,不推m_tail,长度自然加一。参数MAX_SNAKE_LEN决定了蛇的最大长度,设太小后期会绕回覆盖身体,设太大浪费内存。一般 256 够用,地图 40x30 的话,蛇最长也就 1200 格,但实际游戏里不会让你长到那么长。
CheckCollision里有个容易忽略的点:撞自己时,如果蛇头刚好移到当前尾巴的位置,而尾巴这一帧还没缩进,算不算撞?标准做法是先移动尾巴再检测碰撞,或者检测时排除尾巴节点。这份资源如果没处理这个边界,会出现「贴着尾巴走也会死」的玄学现象。
3.2 食物生成与难度曲线
食物生成看着简单,其实有两个坑:一是随机位置不能落在蛇身上,二是难度提升后食物存在时间会缩短。
// FoodManager.cpp 里的生成逻辑 void CFoodManager::Generate(const CSnakeBody& snake, int mapWidth, int mapHeight) { int totalCells = mapWidth * mapHeight; int freeCells = totalCells - snake.GetLength(); if (freeCells <= 0) { // 地图满了,触发通关逻辑 m_bGameOver = true; return; } // 用随机数选一个空闲格子 int targetIndex = rand() % freeCells; int count = 0; for (int y = 0; y < mapHeight; ++y) { for (int x = 0; x < mapWidth; ++x) { if (!snake.Occupies(x, y)) { if (count == targetIndex) { m_foodX = x; m_foodY = y; m_foodType = RollFoodType(); // 普通/加速/减速/加分 return; } ++count; } } } }这段代码用的是「空闲格子索引法」,比「随机生成后重试」更稳定。重试法在蛇很长的时候可能连续随机到蛇身上,导致卡顿甚至死循环。RollFoodType按概率返回食物类型,普通食物概率最高,特殊食物概率随难度等级调整。难度曲线一般用m_level = m_score / 100 + 1这种线性公式,每升一级,蛇的移动间隔减少 10~20 毫秒,特殊食物出现概率提高 5%。
参数调整建议:移动间隔初始值设 200ms,最低不低于 80ms,再低人手反应跟不上。特殊食物里,「加速」和「减速」要成对出现,不然玩家只吃加速不吃减速,难度曲线就崩了。
3.3 双缓冲绘图消除闪烁
VC6.0 的 MFC 绘图默认直接在屏幕 DC 上画,蛇移动时整个窗口重绘,闪烁严重。增强版必须上双缓冲。
// GameView.cpp 的 OnDraw 函数 void CGameView::OnDraw(CDC* pDC) { CRect clientRect; GetClientRect(&clientRect); // 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(pDC, clientRect.Width(), clientRect.Height()); CBitmap* pOldBitmap = memDC.SelectObject(&memBitmap); // 先画到内存 DC memDC.FillSolidRect(&clientRect, RGB(0, 0, 0)); // 黑底 DrawGrid(&memDC); // 画网格 DrawSnake(&memDC); // 画蛇 DrawFood(&memDC); // 画食物 DrawScore(&memDC); // 画分数 // 一次性拷贝到屏幕 DC pDC->BitBlt(0, 0, clientRect.Width(), clientRect.Height(), &memDC, 0, 0, SRCCOPY); // 清理 memDC.SelectObject(pOldBitmap); }CreateCompatibleDC创建的内存 DC 初始只有 1x1 像素,必须选入一个兼容位图才能用。CreateCompatibleBitmap的参数是屏幕 DC 和宽高,不能传内存 DC,否则位图颜色深度不对,画出来是黑白的。BitBlt最后一个参数SRCCOPY表示直接拷贝,不要用SRCINVERT之类的,会出反色效果。
DrawSnake里如果用了Rectangle或RoundRect画蛇身,记得把画笔和画刷都设好,不然默认是白色填充,蛇看起来像一坨白块。我一般用CBrush设成绿色,CPen设成深绿,画出来有立体感。
注意:双缓冲的位图要在
OnSize里重新创建,窗口大小变了位图不跟着变,拉伸后画面会糊。
4. 参数调优与常见翻车现场排查
4.1 编译期报错排查
现象:fatal error C1083: Cannot open include file: 'stdafx.h'原因:工程目录里没有stdafx.h,或者包含路径没设对。 解决:确认stdafx.h在工程根目录,然后在 Project Settings → C/C++ → Preprocessor → Additional include directories 里加上.\。
现象:error C2065: 'xxxx' : undeclared identifier,但变量明明定义了原因:VC6.0 对 for 循环里声明的变量作用域处理跟标准 C++ 不一致,for (int i = 0; ...)里的i在循环外不可见。 解决:把int i提到循环外面声明,或者升级到 VC6.0 SP6 补丁,部分修复了这个问题。
现象:LNK2001: unresolved external symbol _main原因:工程类型选错了,选成了 Console Application,但代码里写的是WinMain。 解决:Project Settings → Link → Project Options 里,把/subsystem:console改成/subsystem:windows。
4.2 运行期异常排查
现象:游戏启动后窗口一片白,蛇和食物都不显示原因:OnDraw里双缓冲的BitBlt没执行,或者内存 DC 没选入位图。 解决:在BitBlt后面加TRACE(_T("BitBlt done\n")),看输出窗口有没有打印。如果没有,说明OnDraw根本没被调用,检查OnInitialUpdate里有没有调Invalidate。
现象:蛇移动速度越来越快,最后直接飞出地图原因:难度曲线里移动间隔减小的公式写错了,比如m_interval = 200 - m_level * 20,等级高了之后m_interval变成负数,SetTimer收到负数会当成超大无符号数,定时器几乎不触发,蛇就瞬移了。 解决:加个下限保护,m_interval = max(80, 200 - m_level * 20)。
现象:吃到食物后分数加了,但蛇没变长原因:Grow函数里只加了长度计数,没推m_head,或者m_tail不该缩的时候缩了。 解决:检查Move和Grow的调用顺序。正确顺序是:先Move,如果吃到食物再Grow。Grow里只推m_head,不推m_tail。
4.3 资源打包与分发注意
现象:在自己机器上跑得好好的,拷到别人机器上就报错原因:动态链接了 MFC 和 CRT,目标机器没装 VC6.0 运行库。 解决:改成静态链接,或者把MFC42.dll、MSVCRT.dll、MSVCP60.dll一起打包。注意MSVCP60.dll是 C++ 标准库,用了std::string或std::vector就会依赖它。
现象:Release 版正常,Debug 版报Debug Assertion Failed原因:Debug 版 MFC 有额外的断言检查,比如AfxGetApp()返回空指针、CWnd对象未创建就调用成员函数。 解决:看断言弹窗里的文件行号,一般是afxwin1.inl或afxcmn.inl,对应到自己的代码里找未初始化的 MFC 对象。
提示:VC6.0 的 Debug 版 exe 不能单独分发,它依赖
MFC42D.dll等调试版运行库,这些库不允许随商业软件分发。
5. 进阶玩法:把分数存进注册表并加一个排行榜
5.1 用 CWinApp 的注册表接口做持久化
VC6.0 的 MFC 提供了SetRegistryKey和GetProfileInt/WriteProfileInt,底层走注册表,比写 ini 文件省事。在CGameApp::InitInstance里调一次SetRegistryKey(_T("SnakeEnhanced")),之后所有WriteProfileInt都会写到HKEY_CURRENT_USER\Software\SnakeEnhanced\SnakeEnhanced下面。
// 保存最高分 void CGameApp::SaveHighScore(int score) { int oldScore = GetProfileInt(_T("Game"), _T("HighScore"), 0); if (score > oldScore) { WriteProfileInt(_T("Game"), _T("HighScore"), score); } } // 读取最高分 int CGameApp::LoadHighScore() { return GetProfileInt(_T("Game"), _T("HighScore"), 0); }GetProfileInt第三个参数是默认值,注册表里没有这个键时返回 0。WriteProfileInt会自动创建键,不用手动RegCreateKey。这套接口在 VC6.0 的 MFC 里稳定可用,比直接调 Win32 注册表 API 少写几十行代码。
5.2 排行榜的排序与截断
排行榜一般存前 5 或前 10 名。用固定长度数组,每次插入新分数时做一次插入排序,然后截断。
#define MAX_RANK 5 struct RankEntry { int score; CString date; }; void CGameApp::AddToRank(int score) { RankEntry ranks[MAX_RANK]; // 先从注册表读出现有排行榜 for (int i = 0; i < MAX_RANK; ++i) { CString key; key.Format(_T("Rank%d"), i); ranks[i].score = GetProfileInt(_T("Rank"), key, 0); ranks[i].date = GetProfileString(_T("Rank"), key + _T("_Date"), _T("")); } // 插入排序 for (int i = 0; i < MAX_RANK; ++i) { if (score > ranks[i].score) { for (int j = MAX_RANK - 1; j > i; --j) { ranks[j] = ranks[j - 1]; } ranks[i].score = score; ranks[i].date = CTime::GetCurrentTime().Format(_T("%Y-%m-%d")); break; } } // 写回注册表 for (int i = 0; i < MAX_RANK; ++i) { CString key; key.Format(_T("Rank%d"), i); WriteProfileInt(_T("Rank"), key, ranks[i].score); WriteProfileString(_T("Rank"), key + _T("_Date"), ranks[i].date); } }CTime::GetCurrentTime().Format返回CString,VC6.0 支持。GetProfileString第三个参数是默认值,返回CString类型。注意key + _T("_Date")这种拼接,CString重载了+运算符,但_T("_Date")是LPCTSTR,拼接结果还是CString,没问题。
排行榜的显示可以放在游戏结束对话框里,用CListCtrl或者直接画在OnDraw里。用CListCtrl的话,记得在OnInitDialog里调SetExtendedStyle(LVS_EX_FULLROWSELECT),不然选中整行会很难看。
5.3 一个我踩过的坑
早期我做排行榜时,把分数和日期存在同一个字符串里,用逗号分隔,读出来再AfxExtractSubString拆。结果日期格式里带了-,跟分隔符冲突,拆出来的分数是乱的。后来改成两个独立的注册表键,一个存分数,一个存日期,再没出过问题。从那以后我每次做持久化,只要字段超过一个,就强制走独立键值,不搞拼接。希望帮到你。
本文还有配套的精品资源,点击获取