贪吃蛇C++源码精讲:游戏循环、数据结构与碰撞检测实战
简介这是一份 C 控制台贪吃蛇小游戏源码包为刚刚接触 C 或想了解游戏开发流程的初学者而准备。压缩包共 19 个文件大小仅 1.52MB包含 3 个 cpp 源文件、1 个 h 头文件、3 个 exe 可执行程序以及 txt 说明文档、docx 分析表、ico 图标等既能通读代码也能直接运行对照效果。已有 417 人学习下载。源码用 wasd 控制蛇的移动涉及标准输出绘制字符界面、键盘输入响应、碰撞检测、蛇身数据结构、随机食物生成以及面向对象封装和异常处理等关键知识点附带的 ASCII 码表、程序刷新屏幕说明和模拟按键监听笔记则有助于理解控制台游戏背后的实现细节。通过阅读和修改这套源码可有效巩固变量、条件、循环、函数等基础语法并逐步掌握游戏循环与常用数据结构在实际项目中的配合方式。1. 贪吃蛇 C 源码用最小系统搞懂游戏循环很多初学者把贪吃蛇当成可以一行行照着敲的练手题等真正打开这份贪吃蛇 C 源码才发现难点根本不在“画出那条蛇”而是怎么把游戏循环跑稳。蛇身移动本质是加头删尾吃食物时要决定尾巴删不删撞墙和撞自己的判定时机差一帧都会鬼畜方向键偶尔反向其实是控制台输入缓冲在多吞字符——这些细节分散在数据结构、操作系统原理和编译原理里一个压缩包却把全部问题集中到了不到两千行的代码中。有些下载站把它标在 C 语言分类下打开正文才知道全是 C 的类和 STL 容器反而更接近现代工程写法。适合刚学完 C 基础语法、正愁“下一步做什么项目”的人也适合需要快速搭一个控制台游戏原型的开发者。把这份源码吃透再去看任何带渲染循环的图形框架思路都会顺畅不少。2. 读源码前先看建模数据结构、游戏循环与碰撞判定2.1 蛇体用什么容器deque 把“头加尾删”变成 O(1)贪吃蛇每一帧都有一个核心动作新头插入、老尾删除。如果图省事用 vector在头部插入元素会让后面所有元素整体后移单步复杂度直接 O(n)。地图超过 30x30、速度提到 15 帧以后帧率稍高就会开始卡顿蛇的运动轨迹会显得“一抖一抖”的。这份源码里常见的做法是用 std::deque双端队列在头部和尾部的插入删除都是常数时间语义上和“蛇爬行”完全一致也是我见过的大多数开源贪吃蛇默认选择的容器。#include deque struct Point { int x, y; bool operator(const Point other) const { return x other.x y other.y; } }; class Snake { public: std::dequePoint body; // 头部在 front尾部在 back enum class Dir { UP, DOWN, LEFT, RIGHT } dir Dir::RIGHT; explicit Snake(int startX, int startY) { body.push_back({startX, startY}); // 初始只有一截出生在场地中央 } Point nextHead() const { Point head body.front(); switch (dir) { case Dir::UP: head.y - 1; break; case Dir::DOWN: head.y 1; break; case Dir::LEFT: head.x - 1; break; case Dir::RIGHT: head.x 1; break; } return head; } void move(bool willGrow) { Point head nextHead(); body.push_front(head); // 头先长出去 if (!willGrow) { body.pop_back(); // 没吃到食物就丢尾巴 } } };这段代码里 move(bool willGrow) 是整个游戏的核心。willGrow 为 false 时push_front 和 pop_back 同时发生蛇看起来是整体平移为 true 时只 push_front 不 pop_back长度加一。调用顺序必须严格是“先算新头、再插头部、最后处理尾部”顺序反过来会出现经典的“增长瞬移”——蛇头还没到位尾巴先减了一截视觉上蛇像跳了一下。参数说明startX 和 startY 是蛇出生坐标通常取地图中央dir 初始设为 RIGHT 是兼容大多数玩家的习惯开局无需操作即可看到蛇在动。dir 用 enum class 而不是裸 int能避免把方向值意外赋给其他整型变量这是 C 工程里值得留意的习惯。那为什么不直接用链表链表也能做到 O(1) 头尾增删但在小地图上每个节点还要多存两个指针缓存不友好遍历自撞检测时会频繁跳指针整体性能反而不如 deque。deque 在内存上是分段连续的遍历更快代码也更短。项目里如果看到有人用 vector 存蛇身那是能用但会越玩越卡的写法建议动手重构。2.2 游戏循环Sleep 只配做“兜底”固定步长才是正道控制台贪吃蛇最常见的循环写法是 while(1) { 处理按键; 移动; 渲染; Sleep(ms); }。这份源码的 Windows 版本如果直接用 Sleep 卡节奏会踩到一个隐蔽问题Sleep(50) 的实际休眠时长受系统时间片影响偏差能到 10ms 以上有时甚至睡到 60ms 才返回蛇速忽快忽慢。更稳的做法是记录上次帧时间用 steady_clock 做差值判断。#include chrono #include thread constexpr int TICK_MS 80; // 目标帧间隔约 12.5 帧/秒 auto lastTime std::chrono::steady_clock::now(); while (running) { auto now std::chrono::steady_clock::now(); auto elapsed std::chrono::duration_caststd::chrono::milliseconds( now - lastTime); if (elapsed.count() TICK_MS) { // 处理输入 // 移动 // 碰撞检测 // 渲染 lastTime now; // 用实际时间校准别用累加固定值 } std::this_thread::sleep_for(std::chrono::milliseconds(1)); }这里把“间隔多久走一帧”和“线程睡多久”彻底分开了。TICK_MS 是目标帧间隔80ms 对新手刚好后期难度提升可以逐步下调到 60、40。sleep_for(1ms) 只是让出 CPU防止空转占满一个核真正决定步长的是上面的 elapsed 判断。注意 lastTime 必须赋成 now 而不是 lastTime TICK_MS否则误差会一帧一帧累积游戏越跑越快或越跑越慢。游戏循环的四个步骤顺序也不能调换先处理输入再算移动然后碰撞最后渲染。如果先渲染再移动玩家看到的蛇形永远比真实坐标慢一帧操作手感发飘方向键感觉“跟不上手”。2.3 碰撞判定先算新头再判定顺序错了就“穿模”碰撞这里的坑远比你想象的多。撞墙最简单坐标比较即可撞自己却频繁误判因为 move(push_front) 之后新头已经在 body 里面了此时从头遍历会把新头自己和自己比必然相等蛇没碰到自己就死了。解决方法是判定时用 nextHead() 先算“未来的头”而不是用已经 push 进去的头。bool checkCollision(const Snake snake, int width, int height) { Point head snake.nextHead(); // 注意这里还没真正移动 // 撞墙 if (head.x 0 || head.x width || head.y 0 || head.y height) { return true; } // 撞自己从下标 1 开始跳过当前头部 for (size_t i 1; i snake.body.size(); i) { if (snake.body[i] head) { return true; } } return false; }这段代码的关键是“判定发生在移动之前”。用 nextHead() 算坐标等于把未来一帧的状态拿到当前帧来预判。还有食物判定正确顺序是先判断新头是否等于食物坐标如果等于本帧 willGrow 传 true然后执行移动最后做死亡判定。如果把吃食物和移动合成一步操作新头就会用旧坐标去对食物总会差一拍。我在盯代码时发现这个点几乎每个人都至少踩一次。常见误写有两种一种是碰撞判定放在 move 之后那就必须跳过 body[0] 再遍历另一种是自以为“反正碰撞在移动前那我先算 nextHead 再 push_front”结果自撞误判继续复现。两种写法都成立但只能选一种混用就是翻车现场。2.4 食物生成随机坐标、出界与重叠一次讲清食物生成是第二个容易出 bug 的地方。新手常直接 random 一个坐标就画上去不检查是否和蛇重叠。地图比较小时蛇长到 10 节以后随机到重叠位置的概率就不低了食物会直接“隐身”在蛇身里。这份源码的处理逻辑一般是 do-while 循环直到生成一个与蛇身无交集的点。#include random Point spawnFood(const Snake snake, int width, int height) { static std::mt19937 rng{std::random_device{}()}; std::uniform_int_distributionint distX(0, width - 1); std::uniform_int_distributionint distY(0, height - 1); Point p; do { p {distX(rng), distY(rng)}; } while (pointInSnake(snake, p)); // 循环直到不重叠 return p; } bool pointInSnake(const Snake snake, const Point p) { for (const auto seg : snake.body) { if (seg p) return true; } return false; }用 mt19937 而不是 rand()是因为 rand() 在 Windows 下随机质量一般且种子取 time(nullptr) 时同一秒内多次启动游戏初始随机序列完全一致玩家连续开两局会看到食物出现在同一个位置。uniform_int_distribution 负责把随机数映射到 0..width-1 的整数范围两个分布对象分别给 x 和 y避免手动取模造成分布偏差。do-while 循环在极端情况下可能空转很久因为蛇占满地图后几乎不可能找到空位。工程级的做法是设置一个最大尝试次数比如循环超过 100 次后直接判定胜利。这个细节很多源码里没写但你想把贪吃蛇做成关卡制游戏胜利条件的判断必须从这条路径走。3. 把源码跑起来环境准备、编译命令与运行参数3.1 工程结构长什么样头文件与源文件的职责划分一份能称得上“工程”的贪吃蛇源码目录一定是有职责边界的。main.cpp 只负责启动 Game 并进入主循环Game 类持有蛇、食物和计分状态Snake 类只管理自己的坐标与方向Food 类只负责生成食物。把逻辑按类拆开不是形式主义——后面你要加图形界面、按键监听、存档功能时只有 Snake 和 Food 的职责足够单一改动才不会波及到其他文件。我见过有人把全部逻辑用两三百行写进 main.cpp加一个暂停功能都要重写整个主循环最后只能推倒重来。解压这份源码后如果看到类似下面的结构说明作者做过模块拆分SnakeGame/ ├── main.cpp # 入口解析参数、实例化 Game、启动循环 ├── Game.h/.cpp # 游戏流程移动、吃食、碰撞、状态流转 ├── Snake.h/.cpp # 蛇体容器、方向枚举、移动 ├── Food.h/.cpp # 食物生成与重叠校验 ├── README.txt # 说明与编译指引 └── Makefile # GNU Make 一键编译注意这里的“移动逻辑”放在 Snake 类里而“吃食、碰撞、计分”放在 Game 类。Snake 不依赖食物和地图边界只提供 nextHead 和 move 两个原语可测试性高。如果你拿到源码后发现 Snake.cpp 里连控制台清屏代码都写了说明耦合已经失控建议先拆再读不然改一个颜色都要全文件搜索。3.2 编译命令MinGW 与 MSVC 两条路线贪吃蛇代码如果依赖 conio.h就必须分清编译工具链。g 在 MSYS2/MinGW 下可以直接编Visual Studio 的 cl.exe 也完全支持。下面这组命令在 Windows 上验证过# MinGW 路线 g -stdc17 -O2 -Wall -Wextra -o snake main.cpp Game.cpp Snake.cpp Food.cpp ./snake 24 24 80 # MSVC 路线开发者命令行 cl /EHsc /W4 main.cpp Game.cpp Snake.cpp Food.cpp /Fe:snake.exe snake.exe 24 24 80关键参数-Wall -Wextra 打开编译警告很多隐蔽问题如“定义了但没使用”“比较符号优先级错误”都会在这里现形-O2 开启优化对小游戏感知不强但能保持和真实项目一致的行为模式/W4 是 MSVC 的最高警告等级。如果链接报未解析符号 _kbhit先检查 conio.h 是否真的在源码里被包含通常是条件编译没有覆盖 Windows 分支导致的。如果 cl 执行不了说明还没进入“开发者 PowerShell”命令行里先初始化环境再编译。源文件多了以后每次敲一长串命令很痛苦。我习惯在项目里放一个 Makefile把依赖关系写清楚CXX g CXXFLAGS -stdc17 -O2 -Wall -Wextra OBJS main.o Game.o Snake.o Food.o snake: $(OBJS) $(CXX) $(CXXFLAGS) -o $ $(OBJS) main.o: main.cpp Game.h Game.o: Game.cpp Game.h Snake.h Food.h Snake.o: Snake.cpp Snake.h Food.o: Food.cpp Food.h Snake.h clean: del *.o snake.exe 2nul del *.obj 2nulMakefile 里 .o 文件的依赖列表写全后面改任何头文件都会自动触发需要重编的源文件不用怕改了 Snake.h 后 Game.cpp 还在用旧版接口。Windows 下用 mingw32-make 执行Linux 下用 make行为一致。3.3 运行参数初始速度、边界尺寸、初始长度我习惯把地图尺寸、初始蛇长、初始帧间隔都暴露成命令行参数因为不同终端窗口尺寸差异很大写死的宽高在别人机器上大概率显示错位。主干逻辑如下struct GameConfig { int width 20; // 地图宽格 int height 20; // 地图高格 int tickMs 80; // 每帧间隔毫秒 int initLen 3; // 初始蛇长 }; int main(int argc, char* argv[]) { GameConfig cfg; if (argc 2) cfg.width std::atoi(argv[1]); if (argc 3) cfg.height std::atoi(argv[2]); if (argc 4) cfg.tickMs std::atoi(argv[3]); if (argc 5) cfg.initLen std::atoi(argv[4]); // 实例化 Game进入主循环 Game game(cfg); game.run(); return 0; }std::atoi 解析失败会返回 0真实项目要加 fallback 逻辑。我一般在解析失败时回退到默认值而不是直接用 0否则 0x0 地图会让游戏一启动就退出。测试期建议把地图宽高设置得比终端行列数小 2 到 4留出地图边框和顶部状态栏的空间否则最后一行分数显示会被滚动条顶掉。3.4 控制台乱码与中文显示代码页不是玄学这是每个在 Windows 下编译 C 控制台程序的人都会遇见的坑。源码文件保存为 UTF-8可 Windows 控制台默认代码页是 936(GBK)打印中文就成了乱码。地图边框用的制表符在没有启用 Unicode 的窗口下也会变成问号或空格。解决方式是 main() 开头显式切换代码页#ifdef _WIN32 system(chcp 65001 nul); #endifchcp 65001 切到 UTF-8实际运行效果会比默认 GBK 模式更符合源码字符集。还有一个容易被忽略的问题切到 65001 后printf 计算中文显示宽度仍然可能不准控制台绘制地图时会出现左右不对称。这就是为什么地图元素我更倾向用 ASCII 的 --- 而不是制表符后者在不同终端下的宽度行为完全不同。4. 避坑手册控制台贪吃蛇最容易翻车的六个场景4.1 方向键偶尔反向输入缓冲在捣鬼现象按左键蛇往右跑按上键蛇往下跑看起来是“反方向”但只是偶尔出现不是一直这样。原因键盘方向键在控制台输入中被识别为两字节扩展码第一字节是 224第二字节才是真正的方向值。很多新手只读第一字节读到 224 后当成普通键处理方向判断全部错位于是出现按一下没反应、再按一下跳两个方向的错觉。解决每次按键先判断是否为 224是扩展码就再读一次 _getch() 取第二个字节。同时要清理输入缓冲连续快速按键时残留的旧方向码会在下一帧生效蛇头会突然转个大弯。这段血泪经验我建议直接写进注释不然两周后回来看代码又会犯。4.2 速度越调越乱Sleep 精度和 TICK 节拍现象地图越大蛇的移动速度越不稳定同一份代码在不同机器上快慢差别明显。原因直接用 Sleep(ms) 卡节奏完全依赖操作系统的定时精度。Windows 默认时间片约 15.6msSleep(20) 实际可能在 15~30ms 之间浮动误差累积后游戏速度根本不是恒定值越玩越快或越玩越慢都有可能出现。解决按第 2.2 节改成 steady_clock 时间差校准。额外提醒一个细节sleep_for(1ms) 本身仍有 1~3ms 误差但因为只用于让出 CPU不参与帧间隔累加不会影响步长稳定。如果发现蛇在高速下仍然抖动把 TICK_MS 调大 10ms 再看多半是终端绘制占据了太多时间。4.3 食物生成后永远吃不到随机位置不可达现象食物偶尔会出现在墙边、角落蛇绕几圈都够不到明明没有碰撞却总是隔着一格。原因随机参数用了固定种子或者 rand() 在初始化时被反复调用导致特定坐标出现概率偏高另一类情况是随机范围没有扣除最边缘一圈食物直接刷到墙外或不可达的凹槽里。解决用 mt19937 uniform_int_distribution范围设在 [1, width-2] 而不是 [0, width-1]把最外层留成墙。同时生成后检查是否与蛇重叠重叠就重新生成。如果你改过地图结构比如加了墙必须额外检查食物所在区域是否与墙封闭区间连通不然仍然会“看得到吃不到”。4.4 碰到自己却没死判定顺序问题现象蛇头已经压到蛇身游戏还在继续屏幕上出现明显的“穿模”。原因碰撞检测在移动之后执行并且遍历时只检查了除头部以外的节点。但移动函数里 push_front 已经把新头算进去了下一帧的 nextHead() 拿的是“更老”的头部坐标去比慢了一拍穿模自然发生。解决统一用“先算 nextHead再检测碰撞然后 move”的顺序。如果你已经写成移动后再检测回溯时把检查下标从 1 改为 0 是不行的因为第 0 个元素就是当前帧的新头比较起来就是自身对自身必然误判。两种方案只能选一种混用是不行的我反复强调这个是因为它真的很难一眼看出来。4.5 蛇增长一段后闪现队尾裁剪时序现象吃到食物后蛇长长了但某帧蛇的中间一段突然“跳”到了新位置整体看起来断了一截。原因在 move() 里先执行 pop_back() 再执行 push_front()中间那段时间里头部坐标还没更新尾部少了一段渲染交替就会闪现。这是典型的时序错乱代码逻辑单看每行好像都没问题组合起来动作顺序就不对。解决严格按 2.1 规定顺序先 push_front再按 willGrow 决定是否 pop_back。把增长与否作为独立参数传进 move不要在同一次调用里既判断食物又改尾巴。把移动语义封装成 move(willGrow) 后这类闪现问题基本绝迹。4.6 控制台光标闪烁system(“cls”) 不是好选择现象分数刷新时整个屏幕闪烁明显蛇跑得越快闪得越厉害肉眼可见的“白屏闪断”。原因每帧调用 system(cls)把整个终端缓冲区全部清空再重绘高速下闪烁不可避免。这算是工期紧时最容易积攒的技术债项目一复杂就更难换掉。解决用 ANSI 转义序列将光标移动到左上角然后只重绘“变化过的格子”而不是整屏。Windows 10 以上的终端默认支持 ANSI只需先切换虚拟终端模式#ifdef _WIN32 system(); // 或使用 SetConsoleMode 开启 ENABLE_VIRTUAL_TERMINAL_PROCESSING #endif std::cout \x1b[H; // 光标回家这种做法把渲染代价降一个数量级闪烁基本消失。SetConsoleMode 是更正规的打开方式但练手项目用 ANSI 序列足够代码也短。5. 进阶改造从控制台到图形界面与存档系统5.1 图形化改造SFML 下要动哪几个模块很多开发者做完控制台版后第一件事就是换图形库最常见的选型是 SFML。从控制台到图形化真正要改的只有三块输入、渲染和坐标映射。输入从 _getch() 改为 sf::Keyboard::isKeyPressed渲染从 printf 到 sf::RectangleShape坐标从“终端字符行列”变成“像素坐标”让每个格子映射到固定像素宽高。#include SFML/Graphics.hpp void render(sf::RenderWindow window, const Snake snake, const Point food, int cellSize) { window.clear(sf::Color::Black); sf::RectangleShape headRect(sf::Vector2f(cellSize - 1, cellSize - 1)); headRect.setFillColor(sf::Color::Green); headRect.setPosition(snake.body.front().x * cellSize, snake.body.front().y * cellSize); window.draw(headRect); // 身体沿用同一套映射颜色稍浅便于区分头尾 for (size_t i 1; i snake.body.size(); i) { sf::RectangleShape seg(sf::Vector2f(cellSize - 1, cellSize - 1)); seg.setFillColor(sf::Color(100, 200, 100)); seg.setPosition(snake.body[i].x * cellSize, snake.body[i].y * cellSize); window.draw(seg); } window.display(); }cellSize 是格子的像素边长建议至少 16px太小时蛇身挤在一起看不出边界。矩形尺寸减 1 是为了给相邻格子留 1 像素缝隙黑背景上蛇身轮廓会更清楚。中心难点是坐标映射游戏逻辑里用的是格子坐标图形层要用像素坐标这个映射关系前期就固定下来后面加动画、粒子效果都靠它。5.2 存档/读档把蛇身坐标序列化想做存档最直接的方式是把蛇身所有坐标、方向和当前分数按顺序写入二进制文件。注意 Point 结构体内存是紧凑的但直接写结构体在跨平台时可能因为字节对齐出问题工程里我一般手动拆成两个 int 分别写。#include fstream void saveGame(const Snake snake, int score, int level, const std::string path) { std::ofstream file(path, std::ios::binary); if (!file.is_open()) return; file.write(reinterpret_castconst char*(score), sizeof(score)); file.write(reinterpret_castconst char*(level), sizeof(level)); int dirInt static_castint(snake.dir); file.write(reinterpret_castconst char*(dirInt), sizeof(dirInt)); size_t len snake.body.size(); file.write(reinterpret_castconst char*(len), sizeof(len)); int x, y; for (const auto p : snake.body) { x p.x; y p.y; file.write(reinterpret_castconst char*(x), sizeof(x)); file.write(reinterpret_castconst char*(y), sizeof(y)); } }写文件时先写分数、关卡、方向、长度再逐个写坐标。读档时按相反顺序读出先重建一个空 Snake再按坐标 push_back。这里有个易错点读出来的方向值必须做合法范围校验直接转成枚举如果遇到损坏文件未定义的枚举值会触发未定义行为最稳妥是读成 int 后 switch 回合法值。5.3 难度曲线速度、分数与惩罚难度曲线不是简单地把 tickMs 每吃一个食物减 5ms。数值设计要照顾心流体验最初 5 个食物之间保持匀速让玩家建立节奏感之后每 5 个食物降一次速每次降幅不超过 10ms最后速度封顶不再降低转而用增加障碍物来提升难度。惩罚机制方面反方向键输入应该被忽略而不是直接判负——街机版按反方向会直接游戏结束但控制台版节奏较慢忽略策略的体验更顺滑。食物数帧间隔(ms)备注0-580初始节奏6-1070轻微提速11-2060明显加速21-3050进入高手区间3040封顶不再提速得分我常用这套每个食物基础分 10 分加上当前蛇身长度值食物生成速度随得分提高而略微加快。这套设计能让玩家在前两分钟内持续感到“刺激度在增长”又不会因为速度过快而立刻翻车。如果你做的是关卡制还可以在每关结束时把蛇长、速度作为下一关的初始值形成递进体验。6. 最后一个技巧用状态机封装游戏流程改 Bug 少一半把游戏流程硬写在一个大 while 里最直接的后果是“暂停”和“结束”没法干净退出。我用的技巧是把游戏状态抽象成枚举每个状态分支独立处理输入和渲染。这一招成本极低但收益非常大加暂停、加计分界面、加游戏结束重开都不会动到主循环的结构。enum class GameState { MENU, RUNNING, PAUSED, GAMEOVER }; GameState state GameState::RUNNING; while (running) { // 独立于帧循环的输入收集 if (kbhit()) { /* ... */ } switch (state) { case GameState::RUNNING: // 移动、碰撞、食物 if (dead) state GameState::GAMEOVER; if (pressedP) state GameState::PAUSED; break; case GameState::PAUSED: if (pressedP) state GameState::RUNNING; break; case GameState::GAMEOVER: if (pressedEnter) resetGame(); break; default: break; } // 渲染按状态选界面 renderState(state); }这套结构的核心价值是把“状态”和“判断”分离。主循环只负责两件事收集输入、按当前状态派发逻辑。你要加一个“设置”界面只需要增加 MENU 分支和对应按键处理其他状态完全不受影响。比在 while 里堆 if(暂停) else if(结束) 的维护成本低得多。我一开始写贪吃蛇时也犯过“所有逻辑一锅炖”的错后来重构为状态机结构才真正体会到什么叫代码的成长空间。从那以后我每次搭控制台或图形小游戏都会先花十分钟把状态枚举列出来强制走一遍“菜单-运行-暂停-结束”的完整路径改起来心里有底写完基本不用返工。这套状态机的习惯希望也能帮到你的项目。本文还有配套的精品资源点击获取