简介:一份基于C++语言与VC6.0集成环境的贪吃蛇增强版源码工程,定位明确:适合C++初学者、游戏开发入门者,以及希望借具体项目巩固链表知识和Windows窗口编程的读者。工程采用双类设计,将蛇封装为独立类,蛇体用单链表存储,完整展示了从数据结构设计到事件响应、定时器控制、状态管理和图形绘制的开发流程。压缩包共58个文件,以cpp和h源码文件为主,同时包含obj、sbr等编译中间文件、exe可执行程序、bmp图像素材以及dsp、dsw等工程配置,整体约8.98MB,内容组织便于对照学习。目前已有178人浏览学习。通过阅读和运行该工程,可直观理解面向对象封装、链表节点增删与遍历、MFC消息处理、定时器调速等关键知识点,还能了解在VC6.0下构建窗口程序的基本套路,是一份结构完整、可直接调试的实战样例。
1. 还在用 VC6.0 写贪吃蛇?增强版到底“增强”在哪
很多人一听到「贪吃蛇游戏增强VC6.0开发」,第一反应是“这年头谁还用 VC6.0”。但如果你翻过高校的课程设计库、老员工的私藏代码包,会发现这条技术线从未真正退场:Windows 图形编程的入门、GDI 绘制的基本功、消息循环的底层理解,所有东西都被这颗小小的蛇装得明明白白。所谓“增强”,不只是加个计分和加速,而是把原本几十行能跑通的“最小贪吃蛇”,升级成有状态机、有碰撞体系、有存档读档、画面不闪的完整小游戏。这篇文章就按这个思路,从窗口骨架讲到避坑排错,再落到存档和双缓冲,让你照着敲就能跑起来,并且知道每一段代码为什么这么写。适合两类人:一是被课程设计或面试题逼着交作业的学生,二是多年后想捡回 C 和 Win32 API 的老开发者。
2. 把窗口先立起来:CreateWindow 消息循环与 WM_TIMER 驱动的第一版骨架
2.1 最小窗口代码:从 WinMain 到 CreateWindow 的关键参数
增强版贪吃蛇首先得有一个能显示画面的宿主,也就是窗口。用 VC6.0 写 Windows 程序,入口是 WinMain,不是 main。你要做的第一件事是注册窗口类、创建窗口、进入消息循环。下面是这个骨架的最小实现,我把它压缩到只有窗口部分,先保证能弹出一个白底窗口。
#include <windows.h> LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR 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", "增强贪吃蛇 - VC6.0", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 640, 480, NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return msg.wParam; } LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); }这段代码的逻辑分四块:WNDCLASS 注册窗口类,CreateWindow 创建实际窗口,消息循环等待输入,WndProc 处理消息。关键参数里,lpfnWndProc 决定了所有消息回调去向,hbrBackground 用系统画刷填充背景,lpszClassName 必须与 CreateWindow 的第一个参数完全一致。如果你是照着教程抄的,最常翻车的就是类名不匹配和编译时链接入口报错——VC6.0 里要确认项目设置是 Windows Application 而不是 Console Application,否则链接器会去找 main。
有了窗口,你已经可以编译运行了。但游戏内容还完全没有,接下来要做的是让窗口定期收到“心跳”——这是整个蛇移动的节拍器。
2.2 把移动时间交给 WM_TIMER:间隔、ID 与消息堆积的隐患
贪吃蛇的移动不需要实时计算帧率,而是每隔一段时间走一格。Windows 原生提供的定时器机制是 SetTimer,它会在指定间隔后向窗口发送 WM_TIMER 消息。你不需要自己写 Sleep 循环,因为那样会把消息队列堵死,窗口拖不动、按键没反应。
#define TIMER_GAME 1 #define SPEED_BASE 200 // 初始 200ms 走一格 // 在窗口创建完成后启动定时器 case WM_CREATE: SetTimer(hwnd, TIMER_GAME, SPEED_BASE, NULL); return 0; // 在 WM_TIMER 中处理蛇的移动 case WM_TIMER: if (wParam == TIMER_GAME) { // 此处先预留,下一章放入移动函数 } return 0; // 窗口销毁时记得清理 case WM_DESTROY: KillTimer(hwnd, TIMER_GAME); PostQuitMessage(0); return 0;SetTimer 的第二个参数是定时器 ID,当程序里只有这一个定时器时它不敏感,但一旦后面加上“加速计时”或者“动画计时”,ID 就必须唯一。SPEED_BASE 的单位是毫秒,数值越小蛇走得越快,200 是起步体验,后面做增强时你会动态改它。WM_TIMER 的 wParam 就是定时器 ID,这里用上可以防止多个定时器互相干扰。
有一个关键坑值得提前说:WM_TIMER 是低优先级消息,如果窗口在处理其他耗时的操作(比如后面要做的碰撞遍历),WM_TIMER 会被延迟。但好消息是 Windows 不会为 WM_TIMER 排队堆积——如果你在 200ms 间隔里只处理了 150ms,系统不会把错过的那次再补给你,而是直接跳过。这意味着蛇不会“瞬移”,只是那一帧稍慢一点点。这不是缺陷,反而方便了你:不用在移动函数里做防重入保护。
2.3 按键方向锁存:WM_KEYDOWN 里别直接改蛇的方向
初写贪吃蛇的人最容易在这样的场景翻车:玩家快速按两个键,蛇的方向被“吃”掉了,或者按了反方向键导致蛇头直接撞到自己身体。这背后的问题是,WM_KEYDOWN 消息与 WM_TIMER 消息是异步的,你在按键处理里直接改了方向变量,下一次定时器触发的移动就会用到这个新方向,但身体路径还没跟上,产生了“掉头撞自己”的判定错误。
// 全局或静态变量 int dirX = 1, dirY = 0; // 当前移动方向 int newDirX = 1, newDirY = 0; // 本次按键要求的新方向 case WM_KEYDOWN: switch (wParam) { case VK_LEFT: if (dirX != 1) { // 仅当当前不是向右 newDirX = -1; newDirY = 0; } break; case VK_RIGHT: if (dirX != -1) { newDirX = 1; newDirY = 0; } break; case VK_UP: if (dirY != 1) { newDirX = 0; newDirY = -1; } break; case VK_DOWN: if (dirY != -1) { newDirX = 0; newDirY = 1; } break; } return 0; // 在 WM_TIMER 移动蛇时,先提交新方向,再移动 case WM_TIMER: if (wParam == TIMER_GAME) { dirX = newDirX; dirY = newDirY; MoveSnake(); // 下一章实现 } return 0;这里的核心思路是“方向锁存”:newDirX 和 newDirY 表达玩家意图,真正的方向 dirX 和 dirY 只在移动前统一提交。这避免了一个在快速操作时很常见的 bug——按键在两个 timer tick 之间连续触发,蛇还没移动就已经改了两次方向。还有一个细节:判反向用的是 dirX != 1,而不是 newDirX != 1,因为 lock 值只反映上一次移动的真实方向,这样更可靠。另外注意 VK_LEFT 对应的是向左,而我设定向右为 x 增大的方向,所以向左判断当前是否向右,逻辑上是反过来的,写代码时很容易搞混,建议在注释里写清楚。
3. 蛇身、食物与碰撞:数据结构选型与判定顺序的落地实现
3.1 蛇身用动态数组:VC6.0 下手动 malloc 的边界控制
蛇身的数据结构直接决定了移动和绘制的复杂度。常见有两种方案:定长数组,比如int snake[400][2],简单但浪费内存且长度封顶;链表,灵活但每次移动都要从头遍历,逻辑复杂。我推荐动态数组:一个能扩容的结构体,蛇的长度上限由内存决定,实际使用时按需申请。
typedef struct { int x; int y; } SnakeNode; typedef struct { SnakeNode *body; // 动态数组 int length; // 当前长度 int capacity; // 已分配容量 } Snake; void Snake_Init(Snake *s) { s->capacity = 20; s->length = 3; s->body = (SnakeNode *)malloc(sizeof(SnakeNode) * s->capacity); // 初始蛇身:从 (10,10) 开始,向右延伸两节 for (int i = 0; i < s->length; i++) { s->body[i].x = 10 - i; s->body[i].y = 10; } } void Snake_Append(Snake *s, SnakeNode newNode) { if (s->length >= s->capacity) { s->capacity *= 2; s->body = (SnakeNode *)realloc(s->body, sizeof(SnakeNode) * s->capacity); } s->body[s->length++] = newNode; } void Snake_Free(Snake *s) { free(s->body); s->body = NULL; s->length = s->capacity = 0; }这段代码里,Snake_Init 用 malloc 分配初始容量 20 个节点,初始长度 3,蛇头在 (10,10),身体依次向左排。Snake_Append 在吃食物后调用,先检查容量,不够就 realloc 翻倍。Snake_Free 在退出或重新开始时释放内存。
手动管理内存是 VC6.0 项目的常态,没人替你回收。这里有两个边界要特别注意:一是 realloc 返回 NULL 时原来的指针还在,直接赋值会泄漏原内存;二是每次 realloc 后,原来拿到的 body 指针可能已经失效,任何保存了旧地址的临时变量都要作废重取。实际项目中我一般把扩容次数限制到 10 次左右,防止蛇长到几千节后内存碎片把系统拖垮。
3.2 移动与自撞判定顺序:先移动还是先判定,差一个字的玄学
移动蛇最直观的做法是“每个节点都往下一个节点的位置挪”,但这是 O(n) 的复制,而且顺序错了会导致整条蛇“缩短”。好一点的做法是“头往前长一节,尾巴砍掉一节”,用数组的位移实现只需要 O(1) 的更新。如果你选的数组方案,每次移动时把除头以外的节点依次前移,代码会简单得多。
// 假设全局有 Snake g_snake; void MoveSnake(void) { // 1. 计算新的头坐标 SnakeNode newHead; newHead.x = g_snake.body[0].x + dirX; newHead.y = g_snake.body[0].y + dirY; // 2. 把整条蛇身往后挪一格(从尾到头) for (int i = g_snake.length - 1; i > 0; i--) { g_snake.body[i] = g_snake.body[i - 1]; } g_snake.body[0] = newHead; } bool CheckCollision(void) { // 撞墙 if (g_snake.body[0].x < 0 || g_snake.body[0].x >= GRID_COLS || g_snake.body[0].y < 0 || g_snake.body[0].y >= GRID_ROWS) { return true; } // 撞自己:从第二节开始,与头比较 for (int i = 1; i < g_snake.length; i++) { if (g_snake.body[0].x == g_snake.body[i].x && g_snake.body[0].y == g_snake.body[i].y) { return true; } } return false; }移动的循环从尾部开始往前覆盖,这样不会丢失节点数据。如果你从头部开始,body[0] 先被覆盖,后面的节点就拿到了错的数据,蛇身会变成一截一截断开的脏数据。自撞判定中,循环从 1 开始,因为 body[0] 和自身比较永远相等,会误判。
我见过的翻车代码里,最典型的就是把“移动”和“判定”顺序写反:先判定后移动,导致蛇在移动后撞到墙却显示“还活着”。正确顺序是移动后立刻判定,凡是要扣血的逻辑都应放在移动完成之后。这个顺序问题被大家叫“玄学”,其实就是流程设计时没画状态图。
3.3 食物生成的随机性:srand 的时间种子为什么不够用
食物生成看着简单,却在连续多局游戏里暴露问题。如果你的 srand 用的是srand(time(NULL)),而 time 是以秒为单位的整数,那么同在一秒内启动的两局会得到完全一样的位置序列。对贪吃蛇来说,就是下一局食物位置和上一局一模一样,玩家很快会背板。增强版如果引入“障碍物重新排布”,这类重复会直接毁掉可玩性。
void GenerateFood(void) { int fx, fy; int maxTries = GRID_ROWS * GRID_COLS; // 用 tick 和当前蛇长混合一个扰动种子 srand(GetTickCount() ^ g_snake.length * 2654435761u); do { fx = rand() % GRID_COLS; fy = rand() % GRID_ROWS; maxTries--; } while (IsOccupied(fx, fy) && maxTries > 0); g_food.x = fx; g_food.y = fy; }GetTickCount 返回系统启动以来的毫秒数,比 time 的精度高得多。混合蛇身长度做了一个整数哈希,进一步降低重复概率。do...while 循环里检查 IsOccupied,确保食物不会生到蛇身上。maxTries 是保护,防止蛇身占满网格时死循环,默认 600 次就放弃,返回一个错误标志交给上层处理。
增强版的随机性不只是“不重复”,你还得保证食物与障碍物不重叠,并且下一局开始时所有障碍物重新随位置摆放。留一个版本号或者关卡种子,可以在玩家“上一局没吃到”时重排,这在后面存档时会用到。
4. 增强玩法拆解:加速阶梯、障碍关卡与状态机切换
4.1 速度阶梯怎么设:WM_TIMER 的间隔调整与上限保护
基础版贪吃蛇的速度是恒定的,增强玩法第一个加的就是“吃食物加速”。加速不是线性的,否则后期会快到人眼跟不上,反应不过来。常见的做法是分段阶梯:每吃 5 个食物提高一档,间隔从 200ms 降到 160ms、120ms、90ms、70ms,封顶 60ms。达到上限后不再加速,让玩家有一个“最高速适应期”。
#define SPEED_STEP_MS 15 #define SPEED_FLOOR_MS 60 void IncreaseSpeed(void) { int currentSpeed = GetTimerInterval(TIMER_GAME); // 读取当前间隔 int newSpeed = currentSpeed - SPEED_STEP_MS; if (newSpeed >= SPEED_FLOOR_MS) { SetTimer(g_hwnd, TIMER_GAME, newSpeed, NULL); } }这里有几个值得注意的设计:SPEED_STEP_MS 每次减少 15ms,而不是按百分比,因为百分比在低间隔时变化太陡。SPEED_FLOOR_MS 是手动设的下限,不能低于 1,否则 SetTimer 的行为不再准确。在 VC6.0 的 32 位 Windows 下,定时器最小精度大约是 10ms 左右,所以我把下限放到 60ms,是为了留出系统调度余量。
加速功能的另一个隐藏问题是“速度与刷新率的联动”。在 60ms 的间隔里,蛇每秒走 16 格,如果你的窗口很小或者网格很密,玩家根本看不清下一步。我一般在加速的同时,把网格尺寸也调小一点,或者把蛇身颜色根据速度渐变,从绿到红,给视觉一个“正在变快”的信号。这个感知设计很加分,但容易被忽略。
4.2 障碍物与关卡数据:一张简单地图表的定义
增强版要引入障碍物,最稳妥的方式不是运行时随机生成复杂迷宫,而是用一张地图表描述。地图表用二维数组定义,0 表示空,1 表示障碍,2 表示食物初始位置。这样关卡设计师(就是你自己)可以手写地图,也便于扩展。
#define GRID_COLS 20 #define GRID_ROWS 15 // 关卡地图:1 为障碍,0 为空 int g_levelMap[GRID_ROWS][GRID_COLS] = { {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0}, // 中间省略,实际按需求手写 {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, }; int IsObstacle(int x, int y) { if (x < 0 || x >= GRID_COLS || y < 0 || y >= GRID_ROWS) return 1; return g_levelMap[y][x]; }障碍物的逻辑只在两处生效:一是碰撞判定里,蛇头碰到障碍物就算死亡;二是食物生成时,要避开障碍物格子。这里我建议用 g_levelMap 而非运行时动态生成,因为测试时你能控制地图形状,方便复现 bug。发布版本里可以做成随机生成,但开发期手写地图会让你定位问题快得多。
障碍物的尺寸也可以增强:单个格子太小,视觉上不够刺激。后面可以考虑“大型障碍”用 2x2 的格子表示,但要注意碰撞判定时头要按四格计算,容易出边界差一格的 bug。我建议第一版只做单格障碍,先把流程跑顺,再考虑组合。
4.3 状态机驱动游戏进程:四个状态之间的切换条件
一个增强版游戏如果没有“暂停”“重新开始”这些状态,和基础版没区别。用状态机管理游戏流程是必须的,它把乱七八糟的标志位统一成一个 enum,switch 清晰明白。
typedef enum { STATE_START, STATE_RUNNING, STATE_PAUSED, STATE_GAMEOVER } GameState; GameState g_state = STATE_START; void ChangeState(GameState newState) { // 离开旧状态时的清理 if (g_state == STATE_RUNNING && newState != STATE_RUNNING) { // 例如关闭暂停菜单、停止计时等 } g_state = newState; // 进入新状态时的初始化 if (newState == STATE_RUNNING) { SetTimer(g_hwnd, TIMER_GAME, SPEED_BASE, NULL); } else { KillTimer(g_hwnd, TIMER_GAME); } }四个状态的含义:START 是标题画面,按空格开始;RUNNING 是正常游戏;PAUSED 是暂停,按 P 进入;GAMEOVER 是撞墙或撞自身后,显示分数,按 R 重来。切换状态的核心是集中处理进入和退出逻辑,比如进入 START 时要复位蛇身、清理食物、重置速度,退出 RUNNING 时要 KillTimer 防止停不下来的移动。你在按键处理里只需要调用 ChangeState 即可,不用散落一堆标志位。
有人说状态机是小题大做,但我做过的所有游戏项目里,凡是后面能顺利加新功能(比如“无敌道具”“穿墙模式”)的,都是因为一开始就有状态机。没做的,最后都陷入改一个功能带崩一片的泥潭。
5. 避坑:VC6.0 贪吃蛇增强开发中 5 个典型的翻车现场
5.1 现象一:蛇越走越快,最后一次跳三格,窗口像抽风
原因:定时器间隔被无限调小,或者你在加速逻辑里把 SetTimer 的间隔设置成了 0。SetTimer 的第二个参数虽然可以用 0,但行为是未定义的,在很多系统上会导致定时器被立即频繁触发。这种情况下蛇的移动不再是“一格一格”,而是一帧跑很远,看起来像瞬移。
解决:在 IncreaseSpeed 里加下限保护,且每次生效前先判断当前是否已经到达下限。同时把 SetTimer 的调用集中到一个函数里,避免多处设置互相覆盖。我建议用一个全局变量保存当前速度档位,比如int speedLevel = 0,然后统一通过 speedTable[speedLevel] 来设置,而不是靠加减法,这样代码里不会出现负值或溢出。
5.2 现象二:内存占用持续上涨,最后程序无响应
原因:最常见的 GDI 泄漏——在 WM_PAINT 或绘制函数里调用 GetDC 后没有 ReleaseDC,或者 CreateCompatibleDC 创建的内存 DC 没有 DeleteDC 释放。这类泄漏不像 malloc 那么显眼,任务管理器里内存只涨不降,最终 Windows 判定为“无响应”。
解决:每对 GetDC / ReleaseDC 必须成对出现,CreateCompatibleDC 对应 DeleteDC。一个小习惯是:绘制函数开头保存 DC,结尾统一释放,中间所有 return 分支都必须经过释放。我写绘制代码时习惯用 goto cleanup 模式,保证任何情况下都不会跳过释放。另外,VC6.0 的调试版里可以用 GDI 对象计数器观察,但更直接的办法就是把绘制逻辑独立到一个函数,用代码审计的方式过一遍。
5.3 现象三:按下反方向键,蛇当场“翻车”撞死自己
原因:按键处理时没有判断当前方向。比如蛇本来向右走,你按了左箭头,方向直接反转,蛇头冲进了自己的脖子。这个是新手的经典错误,而且很容易被当成“我这蛇太灵敏了”而忽视。
解决:在 WM_KEYDOWN 里加方向反转判定,正如第 2.3 节所写。用 dirX != 1 阻止与当前方向完全相反的操作。注意是判断当前真实方向,而不是上一次按键方向,否则连续快速按两个键会漏掉一次反转。我建议把方向锁定和按键处理拆成两个函数,一个管“允许吗”,一个管“改方向”,后续要加“穿墙模式”时只改第一个函数即可。
5.4 现象四:连开两局,食物出现在同一个位置,玩家背板
原因:srand 用 time(NULL),而 time 的精度是秒。两局在同一个秒内启动,time 返回相同值,随机序列完全一样。tim(NULL) 的调用时机如果放在 WM_CREATE 里,第一次启动和第二次启动可能只差几十毫秒,必然命中。
解决:使用 GetTickCount() 或者混合蛇身长度等动态值做种子。第 3.3 节已经给了实现,srand(GetTickCount() ^ (g_snake.length * 2654435761u)) 是一个够用的伪随机方案。更严格一点可以用 timeGetTime(),但需要链接 winmm.lib。对贪吃蛇这个体量,GetTickCount 足够。
5.5 现象五:中文注释和输出在 VC6.0 里乱码
原因:VC6.0 默认的源代码编码不是 UTF-8,也不是 UTF-16,而是系统 ANSI 代码页。如果你的源文件是从 GitHub 或新版编辑器复制的 UTF-8 编码,VC6.0 打开后中文注释会变成一片乱码,严重时会导致编译失败或字符串拼接崩溃。
解决:把源文件用 VC6.0 的“文件->另存为”转成 ANSI 编码,或者在文件开头加#pragma code_page(65001),但这个方法在不同版本之间不稳定,我实际更推荐前者。写中文输出时,用MessageBox或窗口标题栏显示,而不是在控制台里 printf——VC6.0 的工程默认是 Windows 子系统,printf 本身就不该作为主要输出方式。如果你的工程设置了 /SUBSYSTEM:CONSOLE 来调试,记得在发布前改回去。
6. 进阶技巧:双缓冲停止闪烁,再把存档做成能读回来的文件
6.1 双缓冲:用内存 DC 消除抖动的 20 行代码
基础版贪吃蛇在 WM_PAINT 里直接画背景、画蛇、画食物,会产生明显闪烁,因为每次绘制都会清空再重画,显示器刷新跟不上。增强版必须用双缓冲:先在内存里画好整幅画面,再一次 BitBlt 到窗口。这个技巧在 VC6.0 的 GDI 编程里是标配,代码不多但效果立竿见影。
void DrawGame(HWND hwnd, HDC hdc) { // 创建内存 DC 和位图 HDC memDC = CreateCompatibleDC(hdc); HBITMAP bmp = CreateCompatibleBitmap(hdc, WIN_WIDTH, WIN_HEIGHT); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, bmp); // 在内存 DC 上绘制 FillRect(memDC, &(RECT){0, 0, WIN_WIDTH, WIN_HEIGHT}, GetStockObject(WHITE_BRUSH)); // 画蛇、画食物、画障碍物的代码在这里省略 // 一次性把内存画面贴到窗口 BitBlt(hdc, 0, 0, WIN_WIDTH, WIN_HEIGHT, memDC, 0, 0, SRCCOPY); // 清理,顺序不能乱 SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); }这段代码的核心是“绘制动作全部在 memDC 里完成,最后再贴一次”。注意清理顺序:先把旧的位图 SelectObject 回去,再 DeleteObject 新位图,最后 DeleteDC。如果你先删了 bmp 再 SelectObject 还原,会崩溃。VC6.0 的 GDI 对资源泄漏不报错,但资源耗尽时系统会表现得很随机,有的程序卡死有的花屏。
6.2 存档落盘:把蛇身坐标写成可读的自定义文件
增强版的另一大卖点是“下次还能接着玩”。存档格式我不建议用二进制,虽然它小,但调试时不方便。用自定义的文本格式:第一行是版本号,第二行是当前速度档位和分数,第三行开始是蛇身每个节点的坐标。这样做的好处是即使程序崩溃,也能用文本编辑器查看存档内容,方便排查。
void SaveGame(const char *filename) { FILE *fp = fopen(filename, "w"); if (fp == NULL) return; fprintf(fp, "SNAKE_SAVE_V1\n"); fprintf(fp, "%d %d\n", g_speedLevel, g_score); fprintf(fp, "%d\n", g_snake.length); for (int i = 0; i < g_snake.length; i++) { fprintf(fp, "%d %d\n", g_snake.body[i].x, g_snake.body[i].y); } fclose(fp); } void LoadGame(const char *filename) { FILE *fp = fopen(filename, "r"); if (fp == NULL) return; char version[32]; if (fscanf(fp, "%31s", version) != 1 || strcmp(version, "SNAKE_SAVE_V1") != 0) { fclose(fp); return; // 版本不匹配就放弃读取 } fscanf(fp, "%d %d", &g_speedLevel, &g_score); fscanf(fp, "%d", &g_snake.length); for (int i = 0; i < g_snake.length; i++) { fscanf(fp, "%d %d", &g_snake.body[i].x, &g_snake.body[i].y); } fclose(fp); }存档文件的设计有两个细节:版本号必须放在第一行,读取时先比对,不匹配直接放弃,这是防止以后你改了格式旧档崩盘的最后防线。另外,读取时先读出长度,再按长度循环读节点,顺序必须与写入时一致,任何一行多一个空格或少一个换行都可能导致读到垃圾值。VC6.0 的 fscanf 对错误输入的处理很粗糙,不会报错而是直接返回 0,你的 LoadGame 里要留意返回值,必要时对每个 fscanf 做检查。
落地路径已经完整了:从空窗口到可玩蛇,到增强功能,再到避坑和存档,你手里这套方案是能跑完整个开发周期的。我自己的习惯是每完成一个功能就存一个版本,比如 snake_v2.c 是双缓冲版,snake_v3.c 是加存档版,这样改坏了还有后悔药。倒不是多复杂,只是 VC6.0 时代的工程管理本来就是这样:一个小项目,一份清晰的源码,比什么都强。希望帮到你。
本文还有配套的精品资源,点击获取