三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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

1. 项目概述与核心价值

最近在整理硬盘,翻出来一个十几年前刚学C++时写的贪吃蛇游戏源码。看着那青涩的代码,不禁感慨,这玩意儿真是每个C++初学者的“必修课”和“试金石”。今天,我就把这个老项目翻出来,结合现在对C++和游戏开发的理解,重新梳理一遍,带你从零开始,用最纯粹的C++(配合一点Windows API)实现一个控制台下的贪吃蛇。

你可能会问,都202X年了,谁还玩控制台的黑白方块?图形引擎那么多,为啥要从这开始?我的回答是:贪吃蛇是理解游戏循环、状态管理、用户输入和碰撞检测等核心游戏开发概念的最小可行产品(MVP)。它不依赖任何复杂的图形库或游戏引擎,强迫你直面游戏最本质的逻辑。用C++实现,更能让你深刻理解内存、指针(或现代C++的智能指针)、数据结构和面向对象设计。对于想打好基础、理解底层原理的开发者来说,这是一个绝佳的起点。

这个项目适合谁呢?首先是C++的初学者,你学完了语法和数据结构,正愁没地方练手,这就是最好的实战项目。其次是对游戏开发感兴趣但不知从何入手的新手,它能帮你建立最基础的游戏世界观。最后,哪怕是有经验的开发者,回顾一下这种基础实现,也能在架构设计上获得新的启发。

我们将实现一个功能完整的贪吃蛇:键盘控制移动、随机生成食物、吃到食物增长、撞墙或撞到自己身体游戏结束,并实时显示分数。整个项目代码量控制在300行左右,但麻雀虽小,五脏俱全。

2. 整体架构与设计思路拆解

在动手写代码之前,我们先在脑子里把游戏“运行”几遍,把核心模块和数据结构定下来。一个游戏,本质上是一个高速循环,在循环里做三件事:处理输入(Input)、更新游戏状态(Update)、渲染输出(Render)。这就是经典的**游戏主循环(Game Loop)**模型。

对于我们的控制台贪吃蛇,可以这样分解:

2.1 核心数据结构设计

蛇的身体怎么表示?最直观的就是用一个链表或数组来存储每一节身体的坐标(行,列)。我们选择std::vector<std::pair<int, int>>或者std::deque<std::pair<int, int>>deque(双端队列)在头部删除、尾部添加的操作上更高效,因为蛇的移动就是头部前进(添加新坐标),尾部消失(删除旧坐标)。食物就是一个简单的坐标对std::pair<int, int>

游戏地图(控制台窗口)我们用一个二维的“逻辑网格”来模拟,比如设定宽80个字符,高25行。蛇和食物都在这个网格内活动。

2.2 模块划分

  1. 游戏状态管理器(GameState):负责维护当前分数、蛇的位置、食物的位置、游戏是否结束等所有状态。
  2. 输入处理器(InputHandler):监听键盘事件,获取方向指令。这里有个关键点:控制台下的非阻塞输入。我们不能用cin这种会阻塞程序运行等待输入的函数,必须用_kbhit()_getch()来检测是否有按键并立即获取。
  3. 逻辑更新器(GameLogic):这是游戏的大脑。根据当前方向,计算蛇头的新位置。然后进行碰撞检测:是否撞墙?是否撞到自己?是否吃到食物?根据检测结果,更新蛇身、分数和食物位置。
  4. 渲染器(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.jsonlaunch.json)。适合喜欢折腾和追求简洁的开发者。
    • CLion:JetBrains出品,跨平台,智能提示和重构功能强大,但需要付费或教育许可。

对于这个项目,使用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上运行。这是为了教学和实现的简洁性。一个跨平台的版本需要使用如ncurses(Linux)或抽象输入输出层,那会复杂很多。

3.3 项目设置要点

在Visual Studio中:

  1. 新建一个“空项目”或“控制台应用”。
  2. 在源代码文件中添加一个.cpp文件,例如main.cpp
  3. 确保项目配置是Debug x86x64(根据你的系统)。对于这样的小程序,差异不大。

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::deque<std::pair<int, int>> snake; // 食物坐标 std::pair<int, 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::pair<int, 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() { // 使用更好的随机数生成器(C++11) 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}; }

这里使用了C++11的<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::pair<int, 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_cast<std::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类,将SnakeFood也分别抽象成类,每个类负责自己的数据和行为。

class Snake { private: std::deque<std::pair<int, 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 蛇无法控制或移动异常

  • 症状:按下按键没反应,或者蛇朝奇怪的方向移动。
  • 排查
    1. 首先检查Input()函数中的_getch()是否正确获取了字符。可以在switch前加一句cout << "Key pressed: " << key << endl;来调试。
    2. 确认方向锁逻辑是否正确。最常见的问题是条件写反,比如if (state.dir != LEFT) state.dir = RIGHT;,这会导致无法向右转。
    3. 检查Logic()函数中根据dir计算新蛇头坐标的switch语句,UPDOWN对应的Y坐标增减是否正确(记住控制台坐标系Y轴向下为正)。

7.2 食物生成在蛇身体里

  • 症状:食物出现时,有时会和蛇身重叠。
  • 排查
    1. 重点检查GenerateFood()函数中的do...while循环条件。确保onSnake在每次生成新坐标时被正确重置为false
    2. 检查遍历蛇身的循环,是否正确地比较了foodX/foodY和每一节蛇身的坐标。
    3. 一个隐蔽的bug:如果蛇的身体已经填满了几乎整个屏幕,GenerateFood()可能会陷入无限循环,因为找不到空地。虽然这种情况在早期很少见,但稳健的代码应该添加一个最大尝试次数的限制,比如尝试100次后还没找到空地,就判定为游戏胜利(蛇填满了屏幕)。

7.3 游戏速度不稳定或过快/过慢

  • 症状:游戏一卡一卡的,或者快得看不清。
  • 排查
    1. 主循环中的Sleep值是否合适?100毫秒(0.1秒)是常用的起始值。
    2. 是否在Draw()中使用了过于耗时的操作?比如在循环里调用了system("pause")之类的。
    3. 尝试使用第6.2节提到的基于时间的速度控制方法。

7.4 撞墙或撞自己判断失灵

  • 症状:蛇头明明出了边界或者碰到了身体,但游戏没有结束。
  • 排查
    1. 撞墙检测:检查边界条件。我们的游戏区域是从(0,0)(WIDTH-1, HEIGHT-1),边框是#。蛇头坐标newHead.firstnewHead.second是否在[1, WIDTH-2][1, HEIGHT-2]这个开区间内?注意是<=0>=WIDTH-1就判定为撞墙。
    2. 自身碰撞检测:检查循环是否遍历了蛇身的所有部分?注意,如果你是从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->Logic
3. 点击游戏窗口
蛇反向自杀方向锁逻辑错误或缺失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)、流程控制、函数封装,甚至触及了简单的游戏设计模式。

我个人最深的体会是,编程学习,动手实现一个看得见、摸得着的项目,远比死记硬背语法有效得多。在调试贪吃蛇碰撞检测的过程中,你对循环和条件判断的理解会深刻十倍。接下来,你可以尝试给这个游戏添加更多功能,比如不同颜色的食物有不同分数、增加可穿过的“传送门”障碍、或者记录最高分到文件里。每一个新功能的添加,都是对你编程能力的一次挑战和提升。

← 返回列表