如果你以为贪吃蛇只是开学第一课的作业题,那多半是还没真正写完一版。C++版贪吃蛇看起来只是把一条会变长的线搬进控制台,真正写起来却会遇到非阻塞键盘输入、蛇身增长与移动的错位、控制台刷新闪烁、碰撞边界等多重问题。这篇文章是我基于个人实现过程的实战笔记,适合刚学完C++基础语法、想用一个小游戏把STL和状态机串起来的读者,也适合已经写过一两版但总觉得哪里别扭的同学拿来对照排查。
先说结论:贪吃蛇的代码量不大,几百行就能跑起来,但它把“实时交互、数据结构、状态转换、边界条件”凑在了同一个场景里。这也是为什么我建议不要一上来就套图形库,先用控制台把逻辑跑通,再去想画面好不好看。
1. 为什么拿贪吃蛇检验你的C++基本功
1.1 一个看似简单却藏着三重思维挑战的项目
很多人第一次听“用C++实现贪吃蛇”时,脑子里冒出来的方案是:用一个数组存蛇身,每帧循环里把每个节点的坐标往后挪一格,再读键盘方向,然后画屏。听起来简单,但真动手写,你会发现三个麻烦点。
第一是输入。如果你用cin >> direction读方向,程序就会卡在输入上,蛇要等你按下回车才动一下,这根本不算实时游戏。想让它一边前进一边接收键盘指令,必须做非阻塞输入,这在控制台程序里远比想象中麻烦。
第二是蛇身的表示。蛇移动时,除了头部长出新节点,整条身体都要“跟着走”,但又不是简单的所有节点整体平移。用数组做会涉及大量元素搬移,用链表做又难以随机访问;选错数据结构,代码会写得又慢又绕。
第三是碰撞检测的边界。撞墙、咬到自己、吃到食物后身体变长的顺序,哪一个先判断、哪一个后判断,都会直接影响游戏手感。尤其是“刚好吃到食物,但新头位置正好会碰到尾巴”这种边界,很多人第一次写都会判断错。
这三个问题叠加起来,贪吃蛇就不再是“复制一段代码就能交差”的作业,而是一个能逼你认真考虑输入模型、数据结构设计、更新顺序的迷你系统。
1.2 贪吃蛇到底在练什么
如果用一句话概括,贪吃蛇练的是“在一个循环里,把状态管理清楚”。这个说法在游戏开发里特别常见,平时写业务逻辑不一定会遇到,但写一个实时游戏一定绕不开。
具体来说,它会用到几个非常实用的C++知识点:
- STL容器选型:为什么是
std::deque,而不是std::vector或std::list; - 系统编程相关的输入处理:Windows 的
conio.h,Linux/macOS 的termios; - 随机数的正确姿势:
std::mt19937比rand()可靠在哪里; - 状态控制:游戏进行中、暂停、结束这些状态怎么流转;
- 渲染刷新:怎么避免控制台疯狂闪烁。
这些东西单独看都只是零散知识点,贪吃蛇把它们放进了同一个项目里,让你在做完一个“能玩的东西”之后,顺带把这些知识点全部串起来。
所以我给这类项目的定位是:它不是“Hello World”的进阶版,而是“零依赖的小型游戏原型”。写完这一版,后面再接触图形库、事件循环、游戏引擎,你会发现很多概念都是相通的。
2. 环境准备:两条路线,Windows与Linux/macOS并存
2.1 Windows下的控制台输入与光标定位
在Windows上写控制台贪吃蛇,最省事的方案是使用conio.h里提供的_kbhit()和_getch()。
_kbhit()的作用是检测键盘缓冲区里有没有按键,有就返回非零值,没有就立即返回。_getch()则是无回显地读取一个字符,你按方向键时它不会像cin那样要求你按回车。这一对函数组合起来,就能做到“蛇照常移动,有按键时立刻响应”。
方向键在Windows控制台里通常是两个字节的扫描码:第一个字节是 224 或 0,第二个字节才是真正表示方向的值。比如72是上、80是下、75是左、77是右。所以读取方向键时,需要先判断第一个字节,再读第二个字节,否则方向判断会全部错位。
光标定位方面,Windows下推荐用SetConsoleCursorPosition把光标移回窗口左上角重新绘制,而不是调用system("cls")。system("cls")每次都会清空整个缓冲区再重画,屏幕闪烁非常明显,玩起来眼睛难受。这个问题在后面专门讲。
2.2 Linux/macOS下的termios与ANSI转义序列
Linux和macOS没有conio.h,但思路一样:先把终端从“行缓冲、回显输入”的模式切换成“原始模式”,再用select或poll做非阻塞检测。
核心流程是这样的:调用tcgetattr拿到当前终端属性,把ICANON(行缓冲)和ECHO(回显)关掉,然后调用tcsetattr生效。之后用select等待标准输入有数据可读,超时时间设成 0,这样select永远不会阻塞太久。有按键时调用getchar读取即可。程序退出前一定记得把终端属性恢复原样,否则用户退出你的游戏后,终端会一直不回显输入,非常讨厌。
渲染时也不需要system("clear"),直接输出 ANSI 转义序列\033[H就能把光标定位到左上角,然后逐行重绘画面。这个方案在Windows 10以上的新版终端里也能用,但为了稳定,Windows下我还是推荐直接用SetConsoleCursorPosition。
下面是一个简单的原始模式切换封装:
#include <termios.h> #include <unistd.h> #include <sys/select.h> struct termios orig_termios; void enableRawMode() { tcgetattr(STDIN_FILENO, &orig_termios); struct termios raw = orig_termios; raw.c_lflag &= ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, &raw); } void disableRawMode() { tcsetattr(STDIN_FILENO, TCSANOW, &orig_termios); } bool hasInput() { fd_set fds; FD_ZERO(&fds); FD_SET(STDIN_FILENO, &fds); struct timeval tv = {0, 0}; return select(1, &fds, NULL, NULL, &tv) > 0; }2.3 为什么不建议一上来就引入图形库
很多初学者拿到这个项目,第一反应是“我要不要用图形库”。我的建议是:先不要。
图形库会带来额外依赖,Windows上要配链接库,Linux上要装开发包,配置过程可能比写游戏本身还耗时。最关键的还不是配置问题,而是逻辑和渲染一旦耦合在一起,你就很难分清楚“到底是游戏逻辑错了,还是画图错了”。
控制台版本虽然简陋,但它把“逻辑计算”和“字符输出”分开得非常彻底:你想调蛇的移动、碰撞,直接看坐标数据就行;想调画面,只改输出函数里的字符即可。等逻辑全部稳定,再考虑把渲染层换成SDL或者别的图形库,那才是正确的演进路线。
3. 数据结构先行:蛇身、方向与边界模型
3.1 用deque表达蛇身,为什么不用vector或list
蛇身本质上是一个有序坐标序列:头部在最前面,尾巴在最后面,移动时头部新增、尾巴移除。这正好对应“双端队列”的语义。
std::deque支持在头部和尾部做 O(1) 的插入和删除,同时支持下标随机访问。贪吃蛇只需要在头部push_front,在不吃到食物时尾部pop_back,偶尔要遍历所有节点做绘制和碰撞检测。用deque,这三个操作都很顺手。
对比一下另外两个候选:
| 容器 | 头部插入/删除 | 随机访问 | 问题 |
|---|---|---|---|
std::vector | O(n),需要搬移元素 | O(1) | 头插尾删会导致大量元素拷贝 |
std::list | O(1),靠指针修改 | O(n),只能遍历 | 每次渲染和碰撞检测都要从头遍历,而且缓存不友好 |
std::deque | 头部和尾部 O(1) | O(1),分段连续 | 完美贴合“头进尾出”场景 |
有人会说“蛇总共就几十节,vector也足够快”,确实,这个量级下性能差异可以忽略。但写代码不只是为了跑赢这一次,更是为了养成“选型先想清楚”的习惯。deque在语义上就比vector更贴合蛇身模型:蛇只有头尾在变化,内部节点顺序不变。
3.2 坐标系、方向枚举与面板尺寸
我习惯用一个Point结构体表示坐标,方向用枚举来限制取值范围。
struct Point { int x; int y; bool operator==(const Point& other) const { return x == other.x && y == other.y; } }; enum class Direction { Up, Down, Left, Right };面板尺寸给两个常量,WIDTH和HEIGHT,比如 20 x 20。这里有一个容易忽略的点:坐标原点选在哪里,会影响所有边界判断。我习惯让(0, 0)在面板左上角,x向右增大,y向下增大。这样的话,撞墙条件是x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT,非常直观,不用到处减1。
初始化时,蛇身从中间开始,初始长度给3节,方向给Right。初始位置可以根据面板尺寸计算,比如Point{WIDTH / 2, HEIGHT / 2},然后向左依次排列两节。
4. 游戏循环的三段式骨架:输入、更新、渲染
4.1 非阻塞输入:最容易被忽略的第一道坎
游戏循环的本质是“不停地做三件事:读输入、更新状态、渲染画面”。如果读输入是阻塞的,后面的更新和渲染就永远不会执行,这在实时游戏里是不可接受的。
Windows下用_kbhit()检测有没有输入,有才调_getch():
if (_kbhit()) { int ch = _getch(); if (ch == 224 || ch == 0) { ch = _getch(); switch (ch) { case 72: pendingDir = Direction::Up; break; case 80: pendingDir = Direction::Down; break; case 75: pendingDir = Direction::Left; break; case 77: pendingDir = Direction::Right; break; } } }Linux/macOS下用select配合超时时间实现同样效果,具体代码已经在前面给了hasInput(),读取时调用getchar()即可。
这里我必须提醒一个细节:方向键和普通字符键不一样,它会产生两个字节。如果你只读一次_getch(),第一次读回来的是 224,你会把它当成一个普通按键处理,方向完全乱掉。当初我排这个bug排了很久,后来才反应过来是扫描码的问题。
4.2 渲染刷新:清屏与光标回位的取舍
新手最常见的写法是每帧调用system("cls")再重画。这个写法跑起来以后,屏幕会像老式电视机坏掉一样疯狂闪烁。原因很简单:清屏需要重建整个控制台缓冲区,而你的蛇每100毫秒就刷一次,肉眼能明显感觉到画面在闪。
更科学的做法是“定位光标,覆盖重绘”,而不是“清屏重画”。
Windows下的写法:
HANDLE console = GetStdHandle(STD_OUTPUT_HANDLE); COORD zero = {0, 0}; SetConsoleCursorPosition(console, zero);Linux/macOS下,输出\033[H就能把光标定位到左上角。定位之后,你逐行输出新画面,旧画面自然被新内容覆盖,闪烁感会大幅减轻。
还有一个隐藏问题:光标如果一直在画面右下角闪烁,会显得很碍眼。Windows下可以用CONSOLE_CURSOR_INFO把光标隐藏,Linux下用 ANSI 转义\033[?25l,结束游戏时再恢复\033[?25h。这个属于细节里的细节,但做出来立刻会让程序显得高级。
5. 蛇的移动与增长:算法上的小聪明
5.1 只动头和尾
蛇移动最直观的写法是把蛇身所有节点的坐标整个平移一遍,每个节点都变成它前面那个节点的旧位置。这个写法没错,但做起来特别像“搬运工”,每帧都要处理一整条身体的数据重写。
更高效、也更贴近真实蛇滑行动作的做法是:只关心头部和尾部。
每次更新时:
- 根据当前方向算出新头部坐标;
- 把新头部
push_front插入蛇身; - 如果吃到食物,尾部不删除,蛇身变长;
- 如果没吃到食物,尾部
pop_back,长度不变。
这个算法的核心逻辑是:蛇身中间的节点不需要改,它们只是“顺着往前传”。只要头部多一个节点,尾部少一个节点,整条蛇看起来就像整体往前移动了一格。这就像一列火车,车头往前走一节,车尾收回一节,中间车厢的位置根本不用手动搬。
void update() { Point next = snake.front(); switch (dir) { case Direction::Up: next.y--; break; case Direction::Down: next.y++; break; case Direction::Left: next.x--; break; case Direction::Right: next.x++; break; } if (next.x < 0 || next.x >= WIDTH || next.y < 0 || next.y >= HEIGHT) { alive = false; return; } bool willGrow = (next == food); auto endCheck = willGrow ? snake.end() : std::prev(snake.end()); for (auto it = snake.begin(); it != endCheck; ++it) { if (*it == next) { alive = false; return; } } snake.push_front(next); if (willGrow) { spawnFood(); score++; } else { snake.pop_back(); } }注意上面碰撞检测里那个std::prev(snake.end()):如果没有吃到食物,尾巴这一帧本来就要移走,新头和尾巴瞬间重合不算撞到自己;如果吃到了食物,尾巴不会移走,这时撞到任何一节都算死。这个细节如果你不管,会遇到“蛇明明还在正常走,却突然判定咬到自己”的怪问题。
5.2 反向输入与两节蛇的自杀问题
贪吃蛇有个经典规则:方向不能180度反转。蛇正在往右走的时候,你按一下左,游戏应该忽略这个输入,而不是让蛇头直接撞进身体里。
如果只是在读取输入时判断“新方向不等于当前方向的反方向”,看起来够了,但还有更隐蔽的情况:在蛇只有两节时,这一帧你按了上,下一帧又立刻按下,而实际移动还没发生。当前方向是上,按下会被判定为反向并忽略,这可能没问题。但如果你的输入更新发生在移动之后,或者你用了方向缓冲队列处理连续按键,就可能出现“蛇头转向后再反向”导致瞬间自杀。
我的建议是:维护一个pendingDir,每次读取输入时只更新这个“待处理方向”,在update()开始前才统一替换dir。替换时做一次反向检查:
bool isOpposite(Direction a, Direction b) { return (a == Direction::Up && b == Direction::Down) || (a == Direction::Down && b == Direction::Up) || (a == Direction::Left && b == Direction::Right) || (a == Direction::Right && b == Direction::Left); } if (!isOpposite(dir, pendingDir)) { dir = pendingDir; }这样即使一个游戏循环里读到了多个反向按键,也不会造成蛇在电光火石之间掉头撞死。
6. 食物生成与碰撞检测的细节
6.1 随机数不是rand那么简单
老式C教材会告诉你有rand(),但你真的拿它生成食物坐标时,会发现两个问题。第一,rand()的质量一般,尤其是在早期实现里,生成的数值均匀性和周期都一般;第二,如果你用rand() % WIDTH这种方式取坐标,取模会使低位的权重不均匀,在范围不是2的幂时尤其明显。
C++11 引入的<random>库才是现在的标准做法。用std::random_device提供种子,std::mt19937作为生成器,std::uniform_int_distribution生成均匀分布的整数:
std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution<int> distX(0, WIDTH - 1); std::uniform_int_distribution<int> distY(0, HEIGHT - 1); void spawnFood() { do { food = {distX(gen), distY(gen)}; } while (isOnSnake(food)); }do...while循环是为了防止食物生成在蛇身上。严格来说,当蛇几乎占满整个面板时,这个循环可能很久都跳不出去,但正常游戏里蛇没长到那种程度,这个简单做法足够。如果你想做得更严谨一点,可以收集所有空闲格子再随机取一个,但那是后话了。
6.2 一个幽灵Bug:生成的食物和蛇头重叠
生成食物的逻辑如果顺序不对,会出现一种看起来毫无规律的闪退:蛇刚吃完旧食物,新食物直接刷在了蛇头上,下一帧蛇头还没移动,就判定“吃到了食物”,然后蛇立刻又长一节,画面显得非常诡异。
原因通常是spawnFood()在蛇身更新之前被调用,或者生成时只检查了“食物不在蛇身里”,但没考虑蛇头下一步会走到哪。其实食物落在蛇头当前位置问题不大,只要下一帧蛇头已经移开,不会连续触发。真正要注意的是:新食物的坐标必须在每次生成时重新判断,不能复用上一次已经吃掉的旧坐标。
另外,如果你在处理增长顺序时先push_front新头、再判断是否吃到食物、再在没吃到时pop_back,那这一帧的食物判断逻辑要基于“移动之后的新头”。上一节给出的代码已经按这个顺序写了,调用spawnFood()的位置在push_front之后,所以新食物永远不会落在这一帧的新头上。
7. 我踩过的坑与验证性测试清单
7.1 控制台闪烁与刷新频率的平衡
解决闪烁不只是换掉system("cls")这么简单。即使你用了光标回位,如果每次重绘都把整个面板画一遍,还是会有一点点闪烁,尤其在 Windows 老版本终端上更明显。
我的处理是把画面拆成两层:一层是不变的静态内容,比如四周的墙、面板边框、分数显示;一层是每帧变化的蛇身和食物。每帧只更新变化部分,静态内容只在初始化时画一次。对于控制台贪吃蛇,这种“部分刷新”已经很够用。
刷新频率也要控制,不能想多快就多快。通常初速可以设置在 150 毫秒移动一格,随着分数提高逐步缩短到 80 毫秒左右,再快就意义不大了,因为人类反应速度和终端刷新能力都有限。
7.2 边界判定与“最后一刻”碰撞的两种场景
我遇到过一个经典误判:蛇正在向左走,食物在左边墙边。蛇头走到食物那一格时,下一帧本该吃到食物并增长,但因为食物坐标恰好紧贴墙壁,新头在食物格子上时先判了撞墙,导致游戏直接结束,玩家会觉得“明明吃到食物了为什么死了”。其实如果更新顺序正确,新头先到达食物格子,没越界,应该先处理食物增长,下一帧才会走向墙外,而那时玩家已经可以按方向键转向了。所以撞墙必须在移动之前的“新头位置检查”里做,而不是先移动再判断,否则边界总会差一格。
另一个边界是“自身碰撞与尾部移除的先后”。前面已经写了std::prev(snake.end())的处理。如果你把碰撞检测放在pop_back()之后,相当于尾巴已经提前移走一格,此时再遍历整条蛇,就不会出现误判;若放在之前但不跳过尾巴,大概率会在蛇贴边转身时莫名死亡。这两种顺序我都试过,最终确定:碰撞检测在push_front之前做,并区分是否增长,是最稳的顺序。
7.3 回归测试清单:我是怎么确认一版没写错的
没有自动化测试的贪吃蛇,只能靠手动验证。我给自己列了一张清单,每次改完代码都会逐项过一遍:
| 测试项 | 操作 | 预期结果 |
|---|---|---|
| 原地等待 | 开局不动 | 蛇静止,不闪屏,分数不变 |
| 四个方向 | 依次按上、左、下、右 | 方向响应正确,无反向掉头 |
| 反向输入 | 向右时按左 | 该按键被忽略,方向不变 |
| 连续快速按键 | 在一帧内连按上、左 | 最终方向按顺序生效,不会自杀 |
| 食物生成 | 吃完食物后 | 新食物随机出现,且不在蛇身上 |
| 吃到食物增长 | 用调试代码让食物出现在头部正前方 | 蛇长度+1,分数+1,尾巴不移除 |
| 撞墙 | 蛇头朝墙移动不转向 | 游戏正常结束 |
| 撞自己 | 蛇足够长后回头 | 游戏正常结束 |
| 暂停恢复 | 进入暂停后等待再恢复 | 恢复后不丢按键状态 |
这组测试不用很复杂,但每一条都对应一个真实可能出现的问题。比如“连续快速按键”如果没有专门处理,会导致蛇突然反向自杀;没有“暂停恢复”的测试,你很难发现恢复后第一次按键被吞掉的问题。
8. 从“能跑”到“好玩”的扩展方向
8.1 难度递增、计分与暂停功能
基础版跑通后,下一步是让游戏“有节奏”。速度不要写死在一个数值里,而是用一个变量存“每格移动间隔”,每吃一个食物就减少一点。最简单的方式是:
int moveDelayMs = 150; // 每吃一个食物减少3毫秒,最低80毫秒 moveDelayMs = std::max(80, moveDelayMs - 3);暂停功能建议用状态机来做。定义enum class GameState { Menu, Playing, Paused, GameOver },在游戏循环里用switch分派不同状态下的行为。比如暂停状态下不更新蛇身、不生成食物,但仍然检测输入,按P键切回Playing。这样做比用布尔变量硬塞逻辑清晰得多。
计分可以加上“分数倍率”:一次连续吃到多个食物时分数递增,让玩家有冲刺感。不过这个属于数值设计,你完全可以根据自己的喜好调。
8.2 存档与ASCII图形增强
存档功能其实就是一个很小额度的文件读写。把当前蛇身坐标、方向、分数、速度都序列化到文本文件里,游戏开始时检测文件是否存在,按提示决定是否继续上次进度。这个功能可以顺便练一下std::fstream和序列化,工作量不大但收益明显。
画面增强方面,可以在控制台里用不同字符表示不同对象:蛇头用@,身体用#,食物用*,墙壁用#。如果想更漂亮一点,Windows可以打开虚拟终端处理用 ANSI 颜色转义,给蛇身和食物分别上色;Linux/macOS原生就有颜色。控制台字符游戏的视觉升级方案很多,但要注意别为了花哨牺牲刷新速度。
8.3 从控制台到图形界面的演进路径
如果你想把这份代码改成带窗口的图形游戏,最稳妥的路线是:保留现在所有的逻辑层代码,把“渲染层”替换成图形库。我的做法是把游戏拆成两个模块:GameLogic负责蛇身、食物、碰撞、分数;Renderer负责把GameLogic的状态画出来。
控制台版本里,Renderer是输出字符;换成SDL之后,Renderer改成绘制方块图片。因为逻辑层完全不碰屏幕,所以只要接口不变,图形化改造就只是把渲染函数重写一遍,而不是整体推翻重来。
这一步做完之后,你还会接触事件循环、纹理贴图、键盘事件映射等新概念,但你会发现它们的骨架就是你在控制台版本里已经写过的“输入-更新-渲染”循环。到那时候回看这个小小的贪吃蛇,你会明白为什么我一开始坚持推荐先做一个“丑但逻辑清晰的”控制台版本。
我自己在写完这个项目后最大的感受是:贪吃蛇真正花时间的不是“蛇怎么走”,而是“系统怎么配合你”。数据结构的选型、非阻塞输入的实现、刷新方式的取舍,每一个模块单独看都很简单,但把它们捏成一个可玩的整体,才是编程里最值得练的本事。如果你现在写出来的版本能在不吃食物时保持长度不变、食物永远不落在蛇身上、反向按键永远不生效,那恭喜你,这版贪吃蛇已经超过大多数课程作业的标准了。