简介:这是一份 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::deque<Point> 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_cast<std::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_distribution<int> distX(0, width - 1); std::uniform_int_distribution<int> 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++ -std=c++17 -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 = -std=c++17 -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 2>nul & del *.obj 2>nulMakefile 里 .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.6ms,Sleep(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_cast<const char*>(&score), sizeof(score)); file.write(reinterpret_cast<const char*>(&level), sizeof(level)); int dirInt = static_cast<int>(snake.dir); file.write(reinterpret_cast<const char*>(&dirInt), sizeof(dirInt)); size_t len = snake.body.size(); file.write(reinterpret_cast<const char*>(&len), sizeof(len)); int x, y; for (const auto& p : snake.body) { x = p.x; y = p.y; file.write(reinterpret_cast<const char*>(&x), sizeof(x)); file.write(reinterpret_cast<const char*>(&y), sizeof(y)); } }写文件时先写分数、关卡、方向、长度,再逐个写坐标。读档时按相反顺序读出,先重建一个空 Snake,再按坐标 push_back。这里有个易错点:读出来的方向值必须做合法范围校验,直接转成枚举如果遇到损坏文件,未定义的枚举值会触发未定义行为,最稳妥是读成 int 后 switch 回合法值。
5.3 难度曲线:速度、分数与惩罚
难度曲线不是简单地把 tickMs 每吃一个食物减 5ms。数值设计要照顾心流体验:最初 5 个食物之间保持匀速,让玩家建立节奏感;之后每 5 个食物降一次速,每次降幅不超过 10ms;最后速度封顶,不再降低,转而用增加障碍物来提升难度。惩罚机制方面,反方向键输入应该被忽略而不是直接判负——街机版按反方向会直接游戏结束,但控制台版节奏较慢,忽略策略的体验更顺滑。
| 食物数 | 帧间隔(ms) | 备注 |
|---|---|---|
| 0-5 | 80 | 初始节奏 |
| 6-10 | 70 | 轻微提速 |
| 11-20 | 60 | 明显加速 |
| 21-30 | 50 | 进入高手区间 |
| 30+ | 40 | 封顶,不再提速 |
得分我常用这套:每个食物基础分 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(结束) 的维护成本低得多。
我一开始写贪吃蛇时也犯过“所有逻辑一锅炖”的错,后来重构为状态机结构,才真正体会到什么叫代码的成长空间。从那以后我每次搭控制台或图形小游戏,都会先花十分钟把状态枚举列出来,强制走一遍“菜单-运行-暂停-结束”的完整路径,改起来心里有底,写完基本不用返工。这套状态机的习惯,希望也能帮到你的项目。
本文还有配套的精品资源,点击获取