早几年我做 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两个指针。这种方式内存稳定、不产生动态分配,但写起来麻烦,尤其在插入、删除、遍历的时候很容易把下标搞混。
我更推荐直接用vector<Point>。虽然每次在头部插入一个节点会让后续元素整体后移,复杂度是 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 vector<Point>& 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(); vector<Point> 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_cast<unsigned>(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 值 | 单次移动间隔 | 每秒移动格数 | 主观感受 |
|---|---|---|---|
| 200ms | 0.2 秒 | 5 格 | 新手友好,悠闲散步 |
| 150ms | 0.15 秒 | 约 6.7 格 | 经典初始手感 |
| 100ms | 0.1 秒 | 10 格 | 节奏明显加快 |
| 70ms | 0.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.h | EasyX 未安装或平台不匹配 | 重装 EasyX,检查 x86/x64 |
| 画面闪烁 | 未使用 BatchDraw | 开启批处理绘制 |
| 蛇掉头自撞 | 方向输入直接生效 | 增加期望方向延迟逻辑 |
| 食物进入蛇身 | 未做生成合法性校验 | 生成后调用 foodSafe |
| 游戏结束直接消失 | 结束后没有停留等待 | 加入按键循环再退出 |
| 中文乱码 | 字符集与字符串类型不匹配 | 统一使用 TCHAR 或改用英文 |
6. 踩坑心得与进阶扩展方向
6.1 我在 EasyX 上吃过的亏
这个项目最容易翻车的地方不是算法,而是“太早加功能”。我最早的一版还没把移动逻辑跑稳,就急着去加高亮蛇头和动画,结果每次调试都要在几十行代码里来回找 Bug。后来我强迫自己把一个最小可运行版本先完整跑通,也就是“一条三节蛇能移动、能吃到食物、能撞死”,在这个基础上再逐步加视觉效果。这个习惯救了我很多次。
另一个经验是别迷信 vector 的万能。insert(begin(), ...)写起来舒服,但它每插入一次都要把后面全部元素往后搬。对贪吃蛇来说这无所谓,但如果你把同样的写法代入一个需要频繁头插的工业代码里,性能问题就会放大。项目的核心价值就是让你在有安全感的场景下尝试不同数据结构,等价于先练手再上考场。
6.2 从“会跑”到“好玩”的改动清单
如果你看完源码想把项目做成更完整的作品,我给的扩展方向是按成本从低到高排列的:给蛇头和食物加不同颜色,甚至用两张图片替换;加入按键加速功能,按住某个键临时提升移动频率;在地图里随机生成障碍物,生成时必须避开蛇身和必经路径;增加最高分本地保存,把历史纪录绘制在开始界面;用 BFS 给蛇写一个自动寻路 AI,让 AI 在不撞自己的前提下自动找食物。
我个人最推荐先做“自动寻路”,因为它的输入输出非常明确:给定蛇身坐标、食物坐标、当前方向,输出一个合法的新方向。做完之后你会对搜索算法产生直觉,后面学更复杂的路径规划会轻松很多。
写到最后分享一个真实体会:看源码和手敲源码是两种完全不同的体验。网上这类贪吃蛇教程很多,但只有自己把代码逐行敲进去、亲眼看到蛇撞墙死亡,才能真正理解“为什么先碰撞检测再插入蛇头”“为什么方向不能立刻生效”之类的细节。把这个项目玩明白,你对 C++ 常见容器、循环控制、图形刷新的理解会上一整个台阶。