
早几年我做 C 图形界面练习的时候顺手用 EasyX 写过一版贪吃蛇。后来陆陆续续有人拿着类似的“高仿贪吃蛇”源码来问我说为什么别人的版本能做到经典掌机那种手感自己照着抄却总是闪屏、卡顿、按一下方向就原地暴毙。这个项目看上去小其实里面涉及数据结构、输入处理、绘制刷新、碰撞检测、状态控制这些老基本功非常适合拿来练手。这篇文章我把完整实现思路拆开讲顺便把我踩过的坑都列出来源码不用去别处找文中就有精简可运行版。EasyX 这套库的定位很直接把 Windows 图形绘制封装成几个简单函数让用 C/C 写小游戏的人更快看到画面。贪吃蛇是图形编程入门绕不开的“hello world”它数据量小、逻辑规则明确、画面反馈强烈如果你能把这个项目写明白再回去看更复杂的游戏框架会顺畅很多。这篇内容适合刚学完 C 语法、想做点可视化东西的初学者也适合想拿独立小项目练手的开发者。1. 项目启动先想明白“高仿”到底要仿什么1.1 需求拆解不是能做出来就完事很多人写贪吃蛇就是“能跑就行”但“高仿”这两个字意味着你要先定义参考标准。我这一版的参考目标是经典掌机上的那种体验固定大小的方格地图、蛇身一格一格地移动没有平滑插值动画食物是一个独立的小圆点蛇头颜色比蛇身亮一档吃到食物后蛇立刻变长画面左上角有实时分数死亡后停在结算画面显示最终得分。这些体验细节看起来零碎其实每一条都对应一个具体的技术决策。比如“一格一格移动”要求你用一个离散的网格坐标系统而不是按像素自由移动“蛇头颜色更亮”要求绘制时区分下标 0 和其他节点而“立刻变长”要求你在吃到食物时不能删除尾部节点。把需求掰碎了代码结构才不会被写成一坨浆糊。这个项目能影响到的场景也不止“写着玩”。贪吃蛇的核心逻辑可以平移到寻路、队列、状态机、简单 AI 等方向很多人第一个接触的自动寻路 Demo 就是“让 AI 控制蛇吃食物”。所以别觉得它幼稚把底层逻辑想清楚后面很多东西都是同一个套路。1.2 为什么选 EasyX 而不是 QT、SDL选型这件事很实际。贪吃蛇用 QT 写也不是不行但 QT 的信号槽、事件循环、布局管理器对新手来说太重你还没写到游戏逻辑光把一个空白窗口弹出来就得配置半天。SDL 更偏向游戏领域但要自己处理纹理、事件循环、渲染器初始化游戏逻辑本身的比重反而被稀释了。EasyX 的核心价值是“快”initgraph打开窗口solidrectangle画方块outtextxy输出文字一个函数解决一个需求。它把 Windows 底层绘图 API 包装得足够直白正好适合让你把注意力集中在贪吃蛇本身的算法上。缺点也很明确它只能在 Windows 平台跑Linux 和 macOS 用户得换个方案。但如果你做课程设计或者日常练习这是一条最省力的路。2. 数据结构与渲染方案先把底子打好2.1 蛇身用 vector 还是固定数组贪吃蛇的蛇身是一个不断变化的序列头部插入新节点尾部可能删除一个节点。最直觉的做法是固定数组加头尾下标像环形队列那样维护head和tail两个指针。这种方式内存稳定、不产生动态分配但写起来麻烦尤其在插入、删除、遍历的时候很容易把下标搞混。我更推荐直接用vectorPoint。虽然每次在头部插入一个节点会让后续元素整体后移复杂度是 O(n)但贪吃蛇长度撑死几百个节点这点开销可以忽略不计。用 vector 的好处是代码可读性强snake.front()取蛇头insert(begin, newHead)头插pop_back()删尾逻辑一目了然。固定数组和 vector 的取舍可以总结成下表方案优点缺点适合场景固定数组无动态分配内存连续速度快需要维护头尾下标代码易出错嵌入式、追求极致性能vector头插尾删语义直观不需要手动管理下标头部插入是 O(n)有扩容开销游戏节点少学习和维护优先如果你以后想扩展成“超长蛇身大战”那再考虑换成deque因为双端队列头尾插入都是 O(1)而且支持下标访问这种进阶优化放到后期再说。2.2 EasyX 绘制与批处理刷新EasyX 画一个方块就三行代码设置填充色、指定矩形左上角和右下角、调用填充函数。这是它最舒服的地方你能把 90% 的精力放在业务逻辑而不是绘图 API 上。但如果不注意刷新方式画面会疯狂闪烁。闪屏的根源是默认情况下每个绘制函数都会立刻向屏幕输出你画一个方块、屏幕就刷新一次画几十个方块屏幕就被分层刷新几十次人眼看到的就是闪烁和撕裂。解决方法是用批处理BeginBatchDraw()开启内存画布这帧里所有绘制都先画到一块缓冲区最后用FlushBatchDraw()一次性提交。这个思路可以类比成拍视频不是每个镜头都直接直播而是先录制到存储卡剪辑完后再整体发布。游戏每帧重复“先画缓冲、再统一展示”视觉上就稳定多了。2.3 游戏循环的骨架核心循环非常简单永远是同一套四步处理输入、推进游戏状态、绘制画面、等待一定时间。关键在于你要想清楚“等待时间”控制的是什么。贪吃蛇不是每帧都在移动移动频率是由Sleep(speed)决定的比如 speed 150 毫秒就代表每 150 毫秒前进一格。初学者常犯的错误是让蛇在画面循环里连续移动结果蛇飞一样冲出去连按键都来不及响应。正确的思路是每一轮循环里最多移动一步移动完画一帧然后睡一小觉。这样游戏节奏稳定输入也有足够时间被捕捉。3. 手把手实现从空窗口到一条会跑的蛇3.1 工程搭建与环境检查开发环境建议直接用 VS 的社区版创建空项目即可。EasyX 安装是傻瓜式向导它会自己扫描已安装的 VS 版本。安装后在项目里写#include graphics.h如果编译报找不到头文件检查两个地方第一EasyX 是否真的装上了第二项目有没有选对平台32 位和 64 位不能写混。还有一件容易忽略的事项目字符集设置。默认情况下 VS 可能是 Unicode 字符集如果你使用TCHAR和_T()宏问题不大如果你直接拿char*字符串去喂outtextxy在某些配置下中文会显示成乱码。稳妥的做法是先用英文文本把功能跑通再回头处理中文显示。3.2 变量设计把所有状态一次性定好代码开头定义一组常量后面所有逻辑都靠它们协调。格子大小设为 20 像素地图 30×30 格窗口宽高就是 600×600。蛇的坐标我建议直接采用像素坐标每次移动一个 GRID 的整数倍这样绘制时不用做二次换算碰撞检测也简单。方向用枚举类型食物用包含 x、y 的结构体蛇身用前面提到的 vector。分数和速度单独两个变量速度是一个毫秒数后续用来控制难度曲线。初始化时先给蛇身放三节头在最前后面依次往后排。3.3 输入处理按键响应不是越灵敏越好EasyX 项目里最常见的输入写法是_getch()它的问题是阻塞和排队。玩家快速连按多个方向键旧按键会排在缓冲区里等蛇真正移动时再一个个弹出来结果就出现“明明按的是左蛇却向右跑”的鬼畜现象。我的做法是用GetAsyncKeyState实时读取按键状态它不会在系统里排队你每帧读取的是当下实际按住的键。但这也要注意一个坑不能一检测到按键就立刻修改当前移动方向否则玩家在蛇还没移动的时候连续按了“上”和“左”本帧检测“上”生效下一帧检测“左”生效蛇就可能直接掉头自撞。更稳的设计是引入一个“期望方向”变量。每轮只更新期望方向等到真正开始移动这个节点时再判断期望方向是否与当前方向相反如果相反就忽略。这样把输入和动作剥离开手感会好很多。3.4 碰撞检测与死亡结算碰撞检测分两块撞墙和撞自己。撞墙很简单新蛇头坐标如果小于 0 或者大于等于窗口宽高直接判定死亡。自撞稍微绕一点要在新蛇头插入蛇身之前用循环遍历现有蛇身所有节点只要坐标和蛇头相同就判定死亡。很多初学者把新蛇头先插进 vector 再检查自撞结果一检查它永远等于自己蛇刚出生就暴毙。记住顺序先算新蛇头再做碰撞检测通过了才允许插入蛇身。死亡后不要直接closegraph()闪退最好清空画面用大字显示 “Game Over” 和分数再等待玩家按 ESC 退出这样体验更完整。3.5 完整可运行的源码示例下面是精简版完整源码包含上面讨论的所有核心模块可以直接放到 VS 空项目里编译运行。#include graphics.h #include vector #include cstdlib #include ctime #include tchar.h using namespace std; const int GRID 20; // 每格像素 const int COLS 30; // 横向格子数 const int ROWS 30; // 纵向格子数 const int WIDTH COLS * GRID; // 窗口宽 const int HEIGHT ROWS * GRID; // 窗口高 struct Point { int x, y; Point(int X 0, int Y 0) : x(X), y(Y) {} bool operator(const Point p) const { return x p.x y p.y; } }; enum Dir { UP 1, DOWN -1, LEFT 2, RIGHT -2 }; // 食物生成时要避开蛇身 bool foodSafe(const vectorPoint snake, const Point food) { for (const auto p : snake) { if (p food) return false; } return true; } int main() { initgraph(WIDTH, HEIGHT); setbkcolor(RGB(15, 25, 15)); BeginBatchDraw(); vectorPoint snake; int dir RIGHT; int nextDir RIGHT; int score 0; int speed 150; // 初始三节蛇身头在前 snake.push_back(Point(5 * GRID, 5 * GRID)); snake.push_back(Point(4 * GRID, 5 * GRID)); snake.push_back(Point(3 * GRID, 5 * GRID)); srand(static_castunsigned(time(nullptr))); Point food; bool needNewFood true; while (true) { // 1. 输入只记录期望方向不立即生效 if (GetAsyncKeyState(VK_UP) 0x8000) { if (nextDir ! DOWN) nextDir UP; } else if (GetAsyncKeyState(VK_DOWN) 0x8000) { if (nextDir ! UP) nextDir DOWN; } else if (GetAsyncKeyState(VK_LEFT) 0x8000) { if (nextDir ! RIGHT) nextDir LEFT; } else if (GetAsyncKeyState(VK_RIGHT) 0x8000) { if (nextDir ! LEFT) nextDir RIGHT; } // 2. 移动前检查反向防止掉头自撞 if ((nextDir UP dir DOWN) || (nextDir DOWN dir UP) || (nextDir LEFT dir RIGHT) || (nextDir RIGHT dir LEFT)) { nextDir dir; } dir nextDir; // 3. 计算新蛇头 Point head snake.front(); Point newHead head; if (dir UP) newHead.y - GRID; else if (dir DOWN) newHead.y GRID; else if (dir LEFT) newHead.x - GRID; else if (dir RIGHT) newHead.x GRID; // 4. 碰撞检测先撞墙 if (newHead.x 0 || newHead.x WIDTH || newHead.y 0 || newHead.y HEIGHT) { break; } // 再撞自己 bool hitSelf false; for (const auto p : snake) { if (p newHead) { hitSelf true; break; } } if (hitSelf) break; // 5. 更新蛇身头插没吃到食物就删尾 snake.insert(snake.begin(), newHead); if (newHead food) { score; speed (speed 70 ? speed - 5 : speed); needNewFood true; } else { snake.pop_back(); } // 6. 生成新食物必须避开蛇身 if (needNewFood) { do { food.x (rand() % COLS) * GRID; food.y (rand() % ROWS) * GRID; } while (!foodSafe(snake, food)); needNewFood false; } // 7. 绘制一帧清屏、画蛇、画食物、画分数 cleardevice(); for (size_t i 0; i snake.size(); i) { if (i 0) setfillcolor(RGB(120, 255, 120)); else setfillcolor(RGB(60, 200, 90)); solidrectangle(snake[i].x, snake[i].y, snake[i].x GRID - 2, snake[i].y GRID - 2); } setfillcolor(RGB(255, 70, 70)); solidcircle(food.x GRID / 2, food.y GRID / 2, GRID / 2 - 2); settextstyle(20, 0, _T(Consolas)); settextcolor(WHITE); TCHAR s[64]; _stprintf_s(s, _T(Score: %d), score); outtextxy(10, 10, s); FlushBatchDraw(); Sleep(speed); } // 死亡结算画面 cleardevice(); settextstyle(40, 0, _T(Consolas)); settextcolor(RGB(255, 180, 0)); outtextxy(WIDTH / 2 - 100, HEIGHT / 2 - 30, _T(Game Over)); settextstyle(24, 0, _T(Consolas)); settextcolor(WHITE); TCHAR result[64]; _stprintf_s(result, _T(Score: %d), score); outtextxy(WIDTH / 2 - 50, HEIGHT / 2 10, result); FlushBatchDraw(); while (!(GetAsyncKeyState(VK_ESCAPE) 0x8000)) { Sleep(50); } EndBatchDraw(); closegraph(); return 0; }这段代码没有做花哨的皮肤和音效但该有的贪吃蛇体验都有了。改动任何一个小模块都会影响整体手感比如你把Sleep(50)里的 50 改成 500游戏就会变成慢动作回放。源码就是这样牵一发动全身。4. 源码级细节优化从能玩到玩得舒服4.1 防闪烁方案不能只靠碰运气批处理绘制是防闪烁的第一道防线但还有两个小细节值得注意。第一cleardevice()清屏后不要把绘制代码拖太长清屏和提交缓冲之间的耗时越短越好。第二Sleep(speed)必须放在FlushBatchDraw()后面因为睡眠期间不需要再产生任何绘制动作否则玩家长时间盯着一个不动的画面会觉得卡顿。如果你在代码里用了很多outtextxy输出中文提示每个文字都会触发一次字体排版计算这一帧的开销会明显上升。建议把不需要变化的文字帧前只绘制一次或者把分数单独绘制在固定区域别反复刷整行文字。4.2 输入手感期望方向与当前方向分离我已经在源码里用了nextDir这里再补充解释一下为什么这个设计能救你。贪吃蛇的方向判定有个经典 Bug蛇当前向右走游戏先检测到“上”被按下下一瞬间又检测到“左”被按下如果每次检测都立刻改当前方向蛇先向上、再向左看起来就像是原地调头。把输入和动作分离后每帧只更新“想往哪走”真正移动前再判断“这个方向是不是合法方向”只要非法就回退到当前方向。这个模式不仅适合贪吃蛇也适合其他网格移动游戏。很多人把按键手感差异归咎于电脑卡其实根源在这儿。4.3 分数与速度联动别让难度曲线失控源码里速度最小被限制在 70 毫秒也就是说蛇最快大约每秒移动 14 格。超过这个速度后即使你把 Sleep 降到 0游戏画面也不会有更大变化反而 CPU 使用率暴涨让键盘响应和刷新都变得不稳定。下方表格直观展示了不同 speed 值对应的节奏感speed 值单次移动间隔每秒移动格数主观感受200ms0.2 秒5 格新手友好悠闲散步150ms0.15 秒约 6.7 格经典初始手感100ms0.1 秒10 格节奏明显加快70ms0.07 秒约 14.3 格高难度操作紧张分数和速度的联动方式不用太复杂源码里“每吃一个食物减 5 毫秒”就够用了。如果你要做成关卡制可以把速度曲线拆成多个档位吃到第 5 个食物时进入下一档玩家有明确的目标感。5. 常见问题与排查技巧实录5.1 编译报错“找不到 graphics.h”这个问题的标准答案是 EasyX 没装或者版本不匹配。确认一下安装时是否识别到了 VS最好把 VS 和 EasyX 都关闭后重新安装一次。还有一个隐蔽原因你创建的项目是 64 位平台但 EasyX 安装时只检测到 32 位配置需要手动到项目属性里检查平台必要时切换成 x86。5.2 窗口黑色控制台常驻很出戏很多 EasyX 示例为了暂停方便会用system(pause)但这样会把黑色控制台窗口拉出来整个体验瞬间变得“实验味”十足。更好的做法是留在 EasyX 图形窗口里等待按键我的源码里已经改成检测 ESC。如果你更希望停在任何按键上可以把条件改成“检测到任意按键”再跳出循环效果差不多。5.3 食物生成在蛇身上或者永远等不到新食物食物生成时必须调用foodSafe做合法性检查否则概率虽小总有某次rand随机落点会给蛇“送饭到嘴边”。极端情况下蛇身几乎铺满整个屏幕随机生成食物就变成一个低效的碰撞尝试可能卡住很久。这个问题可以用“预先生成候选食物列表”的方式来优化但对当前项目只要蛇长度没有夸张到几十节循环碰撞完全够用。5.4 速度很快时偶尔出现“瞬移”或掉头这种问题九成出在方向输入上。GetAsyncKeyState 读取的是实时状态如果你在移动间隙按了两个方向两个方向可能在下一帧同时被读到。我用else if保证每帧只处理一个方向的优先级再加nextDir延迟生效已经能挡住大部分误操作。如果还觉得手感不清晰可以在每一帧循环开头把按键读取集中做一个快照彻底避免跨帧状态错乱。5.5 中文字体乱码与界面错位outtextxy默认使用项目字符集如果你用的是宽字符直接传中文常量字符串就会乱码。一种省心做法是全部使用英文显示但这不符合“高仿”中文界面的要求。另一种是使用TCHAR配合_T()宏并统一使用_stprintf_s格式化。这样无论在多字节还是 Unicode 字符集下都能保持显示正常。下表汇总一下常见问题的快速定位思路现象可能原因快速处理找不到 graphics.hEasyX 未安装或平台不匹配重装 EasyX检查 x86/x64画面闪烁未使用 BatchDraw开启批处理绘制蛇掉头自撞方向输入直接生效增加期望方向延迟逻辑食物进入蛇身未做生成合法性校验生成后调用 foodSafe游戏结束直接消失结束后没有停留等待加入按键循环再退出中文乱码字符集与字符串类型不匹配统一使用 TCHAR 或改用英文6. 踩坑心得与进阶扩展方向6.1 我在 EasyX 上吃过的亏这个项目最容易翻车的地方不是算法而是“太早加功能”。我最早的一版还没把移动逻辑跑稳就急着去加高亮蛇头和动画结果每次调试都要在几十行代码里来回找 Bug。后来我强迫自己把一个最小可运行版本先完整跑通也就是“一条三节蛇能移动、能吃到食物、能撞死”在这个基础上再逐步加视觉效果。这个习惯救了我很多次。另一个经验是别迷信 vector 的万能。insert(begin(), ...)写起来舒服但它每插入一次都要把后面全部元素往后搬。对贪吃蛇来说这无所谓但如果你把同样的写法代入一个需要频繁头插的工业代码里性能问题就会放大。项目的核心价值就是让你在有安全感的场景下尝试不同数据结构等价于先练手再上考场。6.2 从“会跑”到“好玩”的改动清单如果你看完源码想把项目做成更完整的作品我给的扩展方向是按成本从低到高排列的给蛇头和食物加不同颜色甚至用两张图片替换加入按键加速功能按住某个键临时提升移动频率在地图里随机生成障碍物生成时必须避开蛇身和必经路径增加最高分本地保存把历史纪录绘制在开始界面用 BFS 给蛇写一个自动寻路 AI让 AI 在不撞自己的前提下自动找食物。我个人最推荐先做“自动寻路”因为它的输入输出非常明确给定蛇身坐标、食物坐标、当前方向输出一个合法的新方向。做完之后你会对搜索算法产生直觉后面学更复杂的路径规划会轻松很多。写到最后分享一个真实体会看源码和手敲源码是两种完全不同的体验。网上这类贪吃蛇教程很多但只有自己把代码逐行敲进去、亲眼看到蛇撞墙死亡才能真正理解“为什么先碰撞检测再插入蛇头”“为什么方向不能立刻生效”之类的细节。把这个项目玩明白你对 C 常见容器、循环控制、图形刷新的理解会上一整个台阶。