资讯动态

C++贪吃蛇实战:从零实现游戏循环与碰撞检测

发布时间:2026/8/9 5:34:26 来源:尧图企业网站定制
1. 项目概述与核心价值最近在整理硬盘翻出来一个十几年前刚学C时写的贪吃蛇游戏源码。看着那青涩的代码不禁感慨这玩意儿真是每个C初学者的“必修课”和“试金石”。今天我就把这个老项目翻出来结合现在对C和游戏开发的理解重新梳理一遍带你从零开始用最纯粹的C配合一点Windows API实现一个控制台下的贪吃蛇。你可能会问都202X年了谁还玩控制台的黑白方块图形引擎那么多为啥要从这开始我的回答是贪吃蛇是理解游戏循环、状态管理、用户输入和碰撞检测等核心游戏开发概念的最小可行产品MVP。它不依赖任何复杂的图形库或游戏引擎强迫你直面游戏最本质的逻辑。用C实现更能让你深刻理解内存、指针或现代C的智能指针、数据结构和面向对象设计。对于想打好基础、理解底层原理的开发者来说这是一个绝佳的起点。这个项目适合谁呢首先是C的初学者你学完了语法和数据结构正愁没地方练手这就是最好的实战项目。其次是对游戏开发感兴趣但不知从何入手的新手它能帮你建立最基础的游戏世界观。最后哪怕是有经验的开发者回顾一下这种基础实现也能在架构设计上获得新的启发。我们将实现一个功能完整的贪吃蛇键盘控制移动、随机生成食物、吃到食物增长、撞墙或撞到自己身体游戏结束并实时显示分数。整个项目代码量控制在300行左右但麻雀虽小五脏俱全。2. 整体架构与设计思路拆解在动手写代码之前我们先在脑子里把游戏“运行”几遍把核心模块和数据结构定下来。一个游戏本质上是一个高速循环在循环里做三件事处理输入Input、更新游戏状态Update、渲染输出Render。这就是经典的**游戏主循环Game Loop**模型。对于我们的控制台贪吃蛇可以这样分解2.1 核心数据结构设计蛇的身体怎么表示最直观的就是用一个链表或数组来存储每一节身体的坐标行列。我们选择std::vectorstd::pairint, int或者std::dequestd::pairint, int。deque双端队列在头部删除、尾部添加的操作上更高效因为蛇的移动就是头部前进添加新坐标尾部消失删除旧坐标。食物就是一个简单的坐标对std::pairint, int。游戏地图控制台窗口我们用一个二维的“逻辑网格”来模拟比如设定宽80个字符高25行。蛇和食物都在这个网格内活动。2.2 模块划分游戏状态管理器GameState负责维护当前分数、蛇的位置、食物的位置、游戏是否结束等所有状态。输入处理器InputHandler监听键盘事件获取方向指令。这里有个关键点控制台下的非阻塞输入。我们不能用cin这种会阻塞程序运行等待输入的函数必须用_kbhit()和_getch()来检测是否有按键并立即获取。逻辑更新器GameLogic这是游戏的大脑。根据当前方向计算蛇头的新位置。然后进行碰撞检测是否撞墙是否撞到自己是否吃到食物根据检测结果更新蛇身、分数和食物位置。渲染器Renderer负责把游戏状态“画”到控制台上。清除上一帧的画面根据蛇身和食物的坐标在对应的控制台光标位置输出字符比如‘O’代表蛇身‘*’代表食物并打印分数和游戏结束信息。2.3 关键算法与逻辑蛇的移动不是每一节身体都向前移动一格那样计算量太大。高效的做法是在蛇头方向增加一个新节点作为新的蛇头如果没吃到食物则删除蛇尾的节点。这样整个蛇就“移动”了。食物生成必须随机生成在游戏区域内且不能与蛇身体的任何一节重叠。这里需要一个简单的随机数生成和碰撞检测。碰撞检测与墙碰撞判断蛇头坐标是否超出游戏边界。与自身碰撞遍历蛇身从第二节开始判断是否有节点的坐标与蛇头坐标相同。与食物碰撞判断蛇头坐标是否与食物坐标相同。设计上我倾向于采用一个相对清晰的面向过程模块函数的结构而不是一开始就引入复杂的类体系。这样对于初学者更友好逻辑流也更直观。等基础版本跑通后我们再讨论如何用面向对象重构使其更优雅。3. 开发环境准备与工具选型工欲善其事必先利其器。虽然这个项目很基础但一个好的环境能让你事半功倍。3.1 编译器与IDE编译器MSVC (Microsoft Visual C)或MinGW-w64。在Windows下MSVC与系统集成度最好也是很多教程默认的环境。如果你用的是Visual Studio那已经自带了MSVC。IDE集成开发环境Visual Studio 2022/2019 (Community版)功能强大调试方便对Windows API支持最好。这是我最推荐新手使用的省去配置的麻烦。Visual Studio Code轻量灵活需要自己配置C编译环境安装MSVC或MinGW并配置tasks.json和launch.json。适合喜欢折腾和追求简洁的开发者。CLionJetBrains出品跨平台智能提示和重构功能强大但需要付费或教育许可。对于这个项目使用Visual Studio Community版是最简单的。创建项目时选择“控制台应用”即可。3.2 关键库与API我们主要使用C标准库STL和Windows平台特有的控制台API。iostream,vector,deque,utility,ctime,cstdlib来自STL用于输入输出、数据结构、随机数生成。conio.h这是MSVC特有的头文件提供了_kbhit()和_getch()这两个实现非阻塞输入的关键函数。如果你使用MinGW通常也包含这个头文件。windows.h用于调用Windows API来精细控制控制台比如设置光标位置、修改文本颜色、获取控制台大小等。这能让我们的游戏看起来更“像样”。注意conio.h和windows.h使得代码是平台相关的只能在Windows上运行。这是为了教学和实现的简洁性。一个跨平台的版本需要使用如ncursesLinux或抽象输入输出层那会复杂很多。3.3 项目设置要点在Visual Studio中新建一个“空项目”或“控制台应用”。在源代码文件中添加一个.cpp文件例如main.cpp。确保项目配置是Debug x86或x64根据你的系统。对于这样的小程序差异不大。3.4 一个让控制台“听话”的技巧默认的控制台窗口可能不支持我们需要的功能或者闪烁严重。我们可以在程序开头调用一些Windows API来优化体验#include windows.h void FixConsoleWindow() { HWND consoleWindow GetConsoleWindow(); LONG style GetWindowLong(consoleWindow, GWL_STYLE); style style ~(WS_MAXIMIZEBOX) ~(WS_THICKFRAME); // 禁用最大化按钮和调整大小 SetWindowLong(consoleWindow, GWL_STYLE, style); }这段代码可以固定控制台窗口大小防止玩家不小心拖拽改变大小导致游戏画面错乱。我们会在主函数初始化时调用它。4. 核心模块实现详解现在我们进入具体的代码实现环节。我会分模块讲解关键代码并解释每一处设计的理由。4.1 游戏状态与全局定义首先我们定义一些常量和全局状态变量。虽然全局变量通常不被鼓励但在这样的小型、单一文件中为了代码清晰度可以适当使用。#include iostream #include conio.h #include windows.h #include deque #include random // 游戏区域尺寸单位字符 const int WIDTH 60; const int HEIGHT 20; // 方向枚举 enum Direction { STOP 0, LEFT, RIGHT, UP, DOWN }; // 游戏状态结构体也可以用一个类来封装 struct GameState { bool gameOver; int score; // 蛇的身体每个元素是一个(x,y)坐标。deque便于头部插入和尾部删除。 std::dequestd::pairint, int snake; // 食物坐标 std::pairint, int food; // 当前移动方向 Direction dir; }; GameState state;这里使用std::deque来存储蛇身。deque支持在头部和尾部进行高效的插入和删除操作完美匹配蛇移动时在头部添加新节点、在尾部删除旧节点的需求。4.2 初始化函数游戏开始前需要初始化所有状态。void Setup() { state.gameOver false; state.dir STOP; // 游戏开始前静止 state.score 0; // 初始化蛇放在屏幕中央长度为3 state.snake.clear(); int startX WIDTH / 2; int startY HEIGHT / 2; for (int i 0; i 3; i) { state.snake.push_back({startX i, startY}); // 蛇头在右侧 } // 生成第一个食物 GenerateFood(); // 固定控制台窗口并隐藏光标 FixConsoleWindow(); HideCursor(); }GenerateFood()函数需要确保食物生成在空白区域我们稍后实现。HideCursor()是一个用Windows API实现的小函数用于隐藏控制台闪烁的光标让画面更干净。4.3 输入处理函数这是实现流畅控制的关键。我们必须使用非阻塞的方式读取键盘输入。void Input() { // _kbhit()检查是否有键盘输入不阻塞程序 if (_kbhit()) { switch (_getch()) { // _getch()立刻读取一个字符不等待回车 case a: if (state.dir ! RIGHT) // 防止直接反向移动例如正在向右时不能立即向左 state.dir LEFT; break; case d: if (state.dir ! LEFT) state.dir RIGHT; break; case w: if (state.dir ! DOWN) state.dir UP; break; case s: if (state.dir ! UP) state.dir DOWN; break; case x: // 退出键 state.gameOver true; break; } } }这里有一个非常重要的细节方向锁。当蛇正在向右移动时玩家瞬间按下‘a’向左如果允许蛇头会立刻反向撞向自己的身体第二节导致非玩家所愿的死亡。因此我们加了条件判断禁止直接反向移动。这是很多初学者容易忽略的“坑”。4.4 逻辑更新函数这是游戏最核心的部分每一帧都要调用。void Logic() { if (state.dir STOP) return; // 未开始移动不更新 // 1. 计算新的蛇头位置 std::pairint, int newHead state.snake.front(); // 获取当前蛇头 switch (state.dir) { case LEFT: newHead.first--; break; case RIGHT: newHead.first; break; case UP: newHead.second--; break; // 注意控制台坐标(0,0)在左上角向下Y增加 case DOWN: newHead.second; break; } // 2. 碰撞检测撞墙 if (newHead.first 0 || newHead.first WIDTH-1 || newHead.second 0 || newHead.second HEIGHT-1) { state.gameOver true; return; } // 3. 碰撞检测撞自己 for (auto it state.snake.begin(); it ! state.snake.end(); it) { if (it-first newHead.first it-second newHead.second) { state.gameOver true; return; } } // 4. 将新蛇头加入身体前端 state.snake.push_front(newHead); // 5. 碰撞检测吃食物 if (newHead.first state.food.first newHead.second state.food.second) { state.score 10; GenerateFood(); // 吃到食物生成新的蛇身长度因为push_front已经增加 } else { // 没吃到食物删除蛇尾保持长度不变 state.snake.pop_back(); } }逻辑顺序很重要先计算新位置再检测碰撞最后更新蛇身。检测自身碰撞时理论上可以跳过蛇头自己撞自己但为了代码清晰全遍历也可以。注意吃到食物后只生成新食物不删除蛇尾这样蛇的长度就增加了1。4.5 食物生成函数确保食物生成在游戏区域内的空白处。void GenerateFood() { // 使用更好的随机数生成器C11 static std::random_device rd; static std::mt19937 gen(rd()); std::uniform_int_distribution distX(1, WIDTH - 2); std::uniform_int_distribution distY(1, HEIGHT - 2); int foodX, foodY; bool onSnake; do { onSnake false; foodX distX(gen); foodY distY(gen); // 检查是否与蛇身重叠 for (const auto segment : state.snake) { if (segment.first foodX segment.second foodY) { onSnake true; break; } } } while (onSnake); // 如果重叠重新生成 state.food {foodX, foodY}; }这里使用了C11的random库来生成质量更好的随机数避免传统rand() % N可能带来的分布不均问题。do...while循环确保食物一定生成在空地上。虽然小概率下可能循环多次但对于贪吃蛇的网格规模性能影响可忽略不计。4.6 渲染绘制函数将内存中的游戏状态绘制到控制台屏幕上。为了画面不闪烁我们采用“双缓冲”思想先在内存中构建好一整帧的字符串然后一次性输出。void Draw() { system(cls); // 清屏。注意频繁调用system有性能开销但对于这个小游戏足够了。 // 绘制上边框 for (int i 0; i WIDTH; i) std::cout #; std::cout std::endl; for (int y 0; y HEIGHT; y) { for (int x 0; x WIDTH; x) { // 绘制左右边框 if (x 0 || x WIDTH - 1) { std::cout #; } // 绘制蛇头 else if (x state.snake.front().first y state.snake.front().second) { std::cout O; // 用O表示蛇头 } // 绘制蛇身 else if (std::any_of(state.snake.begin()1, state.snake.end(), [x, y](const std::pairint, int seg) { return seg.first x seg.second y; })) { std::cout o; // 用o表示蛇身 } // 绘制食物 else if (x state.food.first y state.food.second) { std::cout *; } else { std::cout ; } } std::cout std::endl; } // 绘制下边框 for (int i 0; i WIDTH; i) std::cout #; std::cout std::endl; // 显示分数 std::cout Score: state.score std::endl; std::cout Controls: W/A/S/D to move, X to exit. std::endl; if (state.gameOver) { std::cout GAME OVER! Final Score: state.score std::endl; } }这里用system(cls)清屏简单但有效。绘制逻辑就是遍历游戏区域的每一个格子判断这个坐标是边框、蛇头、蛇身、食物还是空地然后输出对应的字符。注意我们通过判断坐标是否在snake容器中来绘制蛇身。std::any_of算法可以简洁地完成这个检查。5. 主循环与游戏流程整合把上面的模块像拼图一样组合起来就形成了游戏的主循环。int main() { Setup(); // 初始化游戏 while (!state.gameOver) { Draw(); // 渲染当前帧 Input(); // 处理玩家输入 Logic(); // 更新游戏逻辑 Sleep(100); // 控制游戏速度100毫秒一帧即10 FPS } // 游戏结束后的处理 Draw(); // 最后绘制一次显示Game Over信息 std::cout Press any key to exit...; _getch(); // 等待一个按键 return 0; }这个循环极其简洁就是经典的“处理输入-更新逻辑-渲染输出”三部曲。Sleep(100)函数用于控制游戏速度100毫秒的间隔使得蛇的移动速度适中。你可以调整这个值来改变游戏难度。实操心得Sleep的精度不高且会阻塞整个线程。在更复杂的游戏中你需要一个基于高精度计时器的、与帧率解耦的游戏循环确保在不同性能的电脑上游戏逻辑更新的时间间隔是稳定的。但对于这个入门项目Sleep足矣。6. 性能优化与体验提升技巧基础版本已经能玩了但体验可能比较“粗糙”。下面分享几个我实践中总结的优化技巧能让你的贪吃蛇更上一层楼。6.1 解决画面闪烁问题system(cls)会清空整个控制台屏幕然后重绘在低速下没问题但感觉不专业。更优的方案是使用Windows API直接操作控制台缓冲区实现局部更新或双缓冲。// 设置控制台光标位置 void GoToXY(int x, int y) { COORD coord; coord.X x; coord.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); } // 在Draw函数中可以改为只更新变化的部分 // 例如只重绘蛇头、旧蛇尾、新食物等位置而不是全屏刷新。这需要更复杂的状态管理记录上一帧哪些位置需要被擦除但能完全消除闪烁实现平滑动画。对于初学者可以先掌握基础的全屏重绘。6.2 更精确的游戏速度控制使用Sleep的问题是如果Input,Logic,Draw执行时间不稳定或者Sleep本身不精确会导致游戏速度飘忽不定。一个简单的改进是计算每一帧的实际耗时然后Sleep剩余时间。#include chrono // ... auto frameStart std::chrono::steady_clock::now(); // ... 执行 Input, Logic, Draw ... auto frameEnd std::chrono::steady_clock::now(); auto frameDuration std::chrono::duration_caststd::chrono::milliseconds(frameEnd - frameStart); if (frameDuration.count() 100) { Sleep(100 - frameDuration.count()); }这样能保证每帧循环的间隔尽量接近100毫秒。6.3 增加难度阶梯让游戏随着分数增加而变快是贪吃蛇的经典设计。int baseSpeed 100; // 基础间隔100ms int speedIncrement 2; // 每得100分间隔减少2ms int currentSpeed baseSpeed - (state.score / 100) * speedIncrement; if (currentSpeed 20) currentSpeed 20; // 设置一个下限 Sleep(currentSpeed);将主循环中的Sleep(100)替换为根据分数计算的动态速度。6.4 使用面向对象重构当你想增加更多功能比如多种食物、障碍物、关卡时面向对象的优势就体现出来了。你可以将GameState结构体升级为一个Game类将Snake、Food也分别抽象成类每个类负责自己的数据和行为。class Snake { private: std::dequestd::pairint, int body; Direction direction; public: void Move(); void Grow(); bool CheckCollision(int width, int height) const; // ... 其他方法 }; class Game { private: Snake snake; Food food; int score; bool isOver; // ... public: void Run(); void ProcessInput(); void Update(); void Render(); };这样的架构更清晰更易于维护和扩展。我建议你在完成基础版本后尝试自己进行这次重构这是提升设计能力的绝佳练习。7. 常见问题排查与调试实录即使代码逻辑清晰实际编写和运行时也难免遇到各种“坑”。这里记录几个最常见的问题和解决方法。7.1 蛇无法控制或移动异常症状按下按键没反应或者蛇朝奇怪的方向移动。排查首先检查Input()函数中的_getch()是否正确获取了字符。可以在switch前加一句cout Key pressed: key endl;来调试。确认方向锁逻辑是否正确。最常见的问题是条件写反比如if (state.dir ! LEFT) state.dir RIGHT;这会导致无法向右转。检查Logic()函数中根据dir计算新蛇头坐标的switch语句UP和DOWN对应的Y坐标增减是否正确记住控制台坐标系Y轴向下为正。7.2 食物生成在蛇身体里症状食物出现时有时会和蛇身重叠。排查重点检查GenerateFood()函数中的do...while循环条件。确保onSnake在每次生成新坐标时被正确重置为false。检查遍历蛇身的循环是否正确地比较了foodX/foodY和每一节蛇身的坐标。一个隐蔽的bug如果蛇的身体已经填满了几乎整个屏幕GenerateFood()可能会陷入无限循环因为找不到空地。虽然这种情况在早期很少见但稳健的代码应该添加一个最大尝试次数的限制比如尝试100次后还没找到空地就判定为游戏胜利蛇填满了屏幕。7.3 游戏速度不稳定或过快/过慢症状游戏一卡一卡的或者快得看不清。排查主循环中的Sleep值是否合适100毫秒0.1秒是常用的起始值。是否在Draw()中使用了过于耗时的操作比如在循环里调用了system(pause)之类的。尝试使用第6.2节提到的基于时间的速度控制方法。7.4 撞墙或撞自己判断失灵症状蛇头明明出了边界或者碰到了身体但游戏没有结束。排查撞墙检测检查边界条件。我们的游戏区域是从(0,0)到(WIDTH-1, HEIGHT-1)边框是#。蛇头坐标newHead.first和newHead.second是否在[1, WIDTH-2]和[1, HEIGHT-2]这个开区间内注意是0和WIDTH-1就判定为撞墙。自身碰撞检测检查循环是否遍历了蛇身的所有部分注意如果你是从snake.begin()开始遍历那么新蛇头刚刚push_front进去的也会在容器中这会导致自己永远和自己碰撞。我们的逻辑是先计算新头newHead用newHead去和容器里旧的身体部分从begin()1开始做比较然后再把newHead加入容器。这个顺序至关重要。7.5 Visual Studio 编译错误错误‘_kbhit’: identifier not found或‘_getch’: identifier not found原因项目配置可能使用了严格的标准模式。解决确保包含了conio.h头文件。如果问题依旧可以尝试在项目属性 - C/C - 预处理器 - 预处理器定义中添加_CRT_SECURE_NO_WARNINGS来禁用某些安全警告但这通常不是根本原因。更可能的是你创建了一个“空项目”但没有正确设置使用Windows SDK。最简单的方法是创建一个“控制台应用”项目模板。把这些常见问题制成表格方便快速查阅问题现象可能原因解决方案按键无反应1. 未使用_kbhit()/_getch()2.Input()函数未被主循环调用3. 控制台窗口未激活1. 检查输入函数调用2. 确保主循环顺序为Draw-Input-Logic3. 点击游戏窗口蛇反向自杀方向锁逻辑错误或缺失在Input()的switch中为每个方向添加防止直接反向的条件判断食物与蛇身重叠GenerateFood()碰撞检测逻辑有误或随机数范围错误1. 调试do...while循环和坐标比较逻辑2. 确保随机数范围在游戏区域内1到WIDTH-2, 1到HEIGHT-2游戏异常加速主循环中没有延时或Sleep值太小在主循环末尾添加Sleep并调整参数绘制闪烁严重使用system(“cls”)全屏清屏考虑使用SetConsoleCursorPositionAPI实现局部更新编译链接错误缺少Windows库或项目配置错误在VS项目属性 - 链接器 - 输入 - 附加依赖项中添加kernel32.lib;user32.lib写完代码跑起来看着小蛇在自己的控制下移动、吃食物、成长最后因为自己的一个失误而结束这种成就感是单纯看书学习无法比拟的。这个项目虽然小但它串联起了C的多个核心知识点标准库容器deque,pair、流程控制、函数封装甚至触及了简单的游戏设计模式。我个人最深的体会是编程学习动手实现一个看得见、摸得着的项目远比死记硬背语法有效得多。在调试贪吃蛇碰撞检测的过程中你对循环和条件判断的理解会深刻十倍。接下来你可以尝试给这个游戏添加更多功能比如不同颜色的食物有不同分数、增加可穿过的“传送门”障碍、或者记录最高分到文件里。每一个新功能的添加都是对你编程能力的一次挑战和提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价