ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++贪吃蛇游戏开发:从零实现控制台游戏与核心算法解析

2026/8/10 14:16:36 拓冰建站 浏览量
C++贪吃蛇游戏开发:从零实现控制台游戏与核心算法解析

1. 项目概述:为什么选择Dev-C++和贪吃蛇?

如果你在找一门编程语言入门,或者想重温一下大学时在机房敲代码的感觉,C++和贪吃蛇这个组合,绝对是经典中的经典。我当年就是从这玩意儿开始,第一次感受到了“创造”一个能跑起来的程序的快乐。现在各种IDE(集成开发环境)花里胡哨,功能强大,但对于初学者,尤其是想纯粹理解代码逻辑和编译过程的朋友来说,Dev-C++这种轻量级、近乎“裸奔”的IDE,反而更能让你聚焦于代码本身。

Dev-C++是个老家伙了,界面复古,但胜在简单直接。它没有现代IDE那些智能提示、代码补全的“保姆级”服务,这恰恰逼着你去记住每一个函数名、每一个头文件。写贪吃蛇这种控制台小游戏,用不到复杂的图形库,核心就是逻辑控制——蛇怎么动、食物怎么生成、撞墙和撞自己怎么判断。这个过程,能把C++里最基础也最重要的几个概念串起来:循环、条件判断、数组(或者链表)、函数封装,还有那么一点点面向对象的雏形(比如把蛇抽象成一个对象)。

网上源码很多,但“复制粘贴”和自己“从零敲出来”是两码事。前者你得到的只是一个能运行的黑框框,后者你能清楚地知道每一行代码为什么在那里,出了问题该去哪找。这篇内容,我就带你走一遍后者的路,从创建一个空项目开始,到一条活蹦乱跳的贪吃蛇在屏幕上跑起来。我会把完整的源码贴出来,但更重要的是,我会拆解每一个模块的设计思路和容易踩的坑。

2. 开发环境准备与项目创建

2.1 Dev-C++的安装与配置

首先,你得有个Dev-C++。虽然它古老,但依然能在其官网或一些可靠的软件下载站找到。安装过程没什么坑,一路“Next”就行。安装完成后,第一次启动可能会让你选择语言和主题,按喜好来。

这里有个关键点:确保你的Dev-C++使用的是TDM-GCC或MinGW编译器。这是它在Windows上编译C/C++代码的核心。检查方法是:打开Dev-C++,点击顶部菜单栏的“工具(Tools)” -> “编译选项(Compiler Options)”。在“编译器(Compiler)”标签页下,确认“编译器集(Compiler set to use)”是“TDM-GCC …”或“MinGW …”开头的。如果不是,你需要手动配置或重新安装带编译器的版本。

注意:有些绿色版或精简版可能不带编译器,导致你写完代码点编译没反应。最稳妥的方法是去下载包含TDM-GCC的完整安装包。

接下来,我们创建一个新项目。不要直接新建源文件,创建项目能更好地管理文件。

  1. 点击“文件(File)” -> “新建(New)” -> “项目(Project…)”。
  2. 在弹出的窗口里,选择“Console Application”(控制台应用程序),在右边选择“C++项目”,给你的项目起个名字,比如“MySnakeGame”。
  3. 选择一个干净的文件夹来存放你的项目文件,然后点击“保存”。
  4. Dev-C++会自动为你生成一个main.cpp文件,里面包含了一个最基本的main函数框架。我们可以在这个基础上开干。

2.2 控制台游戏的基础设置

贪吃蛇游戏运行在Windows控制台(那个黑窗口)里。我们需要对这个黑窗口进行一些初始化设置,让它更适合我们的游戏。

首先,我们要包含必要的头文件。在main.cpp的最开头,写上:

#include <iostream> #include <conio.h> // 用于_getch()非阻塞输入 #include <windows.h> // 用于控制台API函数 #include <cstdlib> // 用于rand()和srand() #include <ctime> // 用于time() using namespace std;

解释一下:

  • <iostream>:标准输入输出,虽然我们这个游戏主要用Windows API画图,但保留它以备不时之需。
  • <conio.h>:这是关键。里面的_kbhit()_getch()函数能让我们检测键盘输入而不阻塞程序运行(不像cin会卡住等待)。
  • <windows.h>:提供了大量Windows系统函数,我们将用它来控制光标位置、设置颜色、获取控制台大小等。
  • <cstdlib><ctime>:用于随机数生成,给食物一个随机位置。

接下来,在main函数一开始,我们就对控制台进行“装修”:

int main() { // 1. 设置控制台窗口标题 SetConsoleTitle("My Snake Game - By YourName"); // 2. 隐藏光标,让控制台不显示那个闪烁的光标,画面更干净 HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cursorInfo; GetConsoleCursorInfo(hConsole, &cursorInfo); cursorInfo.bVisible = false; // 隐藏光标 SetConsoleCursorInfo(hConsole, &cursorInfo); // 3. 禁用控制台窗口的快速编辑模式和插入模式,防止鼠标点击暂停游戏 HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE); DWORD prev_mode; GetConsoleMode(hInput, &prev_mode); SetConsoleMode(hInput, ENABLE_EXTENDED_FLAGS | (prev_mode & ~ENABLE_QUICK_EDIT_MODE)); // 4. 设置随机数种子 srand((unsigned int)time(NULL)); // ... 游戏主逻辑将在这里编写 return 0; }

这几步设置非常重要,尤其是隐藏光标和禁用快速编辑模式。我见过不少新手写的贪吃蛇,玩着玩着不小心点了一下黑窗口,游戏就卡住了,就是因为控制台进入了“快速编辑”模式。上面的代码能有效避免这个问题。

3. 游戏核心逻辑设计与数据结构

在动手写代码前,我们必须先想清楚游戏里有哪些“东西”,以及它们怎么表示。这就是设计数据结构。

3.1 地图、蛇与食物的抽象

我们用一个二维的字符数组来表示游戏地图(或者说画布)。比如,定义一个宽80,高25的区域。

const int WIDTH = 80; const int HEIGHT = 25; char canvas[HEIGHT][WIDTH]; // 画布数组

这个canvas数组里的每一个字符,就代表了屏幕上的一个点。我们可以约定:

  • 空格' '表示空地。
  • '#'表示墙壁(边界)。
  • '@'表示蛇头。
  • '*'表示蛇的身体。
  • '$'表示食物。

蛇怎么表示?蛇是一节一节的,会动,会变长。最直观的方法是用一个数组来存储每一节身体的坐标。但这里有个问题:蛇移动时,我们需要在数组头部插入新的蛇头位置,并在尾部删除旧的蛇尾位置。用普通数组做插入删除效率很低。因此,一个更好的选择是使用双向队列(deque),它支持在头尾两端高效地插入和删除。

不过,为了更清晰地展示逻辑,我们先使用一个结构体数组和两个变量来手动管理蛇头和蛇尾,这能让你更深刻地理解移动过程。我们定义一个Point结构体表示坐标,然后用数组存储蛇身。

struct Point { int x; int y; }; Point snake[WIDTH * HEIGHT]; // 理论上蛇最长可以占满整个画布 int snakeLength; // 当前蛇的长度 int head, tail; // 指向蛇头和蛇尾在数组中的索引(环形数组思想)

这里用了一个小技巧:我们把snake数组想象成一个环。head指向当前蛇头的位置,tail指向当前蛇尾的位置。当蛇移动时,新的蛇头坐标会覆盖掉tail位置旧的蛇尾坐标(如果没吃到食物),然后headtail指针向前移动一格。这样就模拟了队列(FIFO)的行为,避免了大规模的数据搬移。这是实现蛇移动的核心技巧。

食物就简单了,一个Point变量足矣。

Point food;

3.2 方向控制与游戏状态

蛇有四个移动方向:上、下、左、右。我们用整数常量来表示。

enum Direction { STOP = 0, UP, DOWN, LEFT, RIGHT }; Direction dir;

STOP表示游戏初始或暂停状态。dir变量存储蛇当前的行进方向。

游戏还需要一些全局状态变量:

bool isGameOver; int score; int speed; // 控制游戏速度,值越大,蛇移动越慢(Sleep时间越长)

isGameOver标志游戏是否结束。score是得分,吃一个食物加多少分可以自己定。speed用来控制游戏的难度,通过调节每次移动后Sleep()的毫秒数来实现。

4. 核心函数模块实现

有了设计图,现在开始砌砖,把一个个功能模块实现出来。

4.1 初始化函数:Init()

这个函数负责在游戏开始前,把所有东西摆到正确的位置。

void Init() { isGameOver = false; dir = STOP; // 游戏刚开始,蛇不动 score = 0; speed = 100; // 初始速度,100毫秒移动一次,可调整 // 初始化蛇:放在地图中央,初始长度为3 snakeLength = 3; head = 0; tail = snakeLength - 1; int startX = WIDTH / 2; int startY = HEIGHT / 2; for (int i = 0; i < snakeLength; ++i) { snake[i].x = startX - i; // 蛇头在最右,身体向左延伸 snake[i].y = startY; } // 生成第一个食物 GenerateFood(); // 初始化画布:清空,并绘制边界 for (int i = 0; i < HEIGHT; ++i) { for (int j = 0; j < WIDTH; ++j) { if (i == 0 || i == HEIGHT - 1 || j == 0 || j == WIDTH - 1) { canvas[i][j] = '#'; // 边界 } else { canvas[i][j] = ' '; // 内部空地 } } } // 把蛇和食物画到画布上 DrawSnakeAndFood(); }

GenerateFood()DrawSnakeAndFood()是我们接下来要实现的函数。初始化时让蛇水平放置,头朝右。

4.2 绘制函数:Draw()

这个函数负责将内存中的canvas画布“打印”到控制台屏幕上。直接逐行逐列输出整个数组效率很低,因为每次都要重绘整个屏幕。更高效的做法是只更新发生变化的位置。但对于初学者,我们先采用简单清晰的全屏重绘,理解了原理后再优化。

我们实现一个Draw()函数和一个辅助函数DrawSnakeAndFood()

void DrawSnakeAndFood() { // 先清除画布上旧的蛇和食物(将非边界区域重置为空格) for (int i = 1; i < HEIGHT - 1; ++i) { for (int j = 1; j < WIDTH - 1; ++j) { canvas[i][j] = ' '; } } // 绘制食物 canvas[food.y][food.x] = '$'; // 绘制蛇身(从尾到头画,确保蛇头最后画,万一有重叠,头覆盖身体) for (int i = tail; i != head; i = (i + 1) % (WIDTH * HEIGHT)) { canvas[snake[i].y][snake[i].x] = '*'; } // 绘制蛇头 canvas[snake[head].y][snake[head].x] = '@'; } void Draw() { // 将光标移动到控制台左上角(0,0),实现“清屏”效果 COORD coord = {0, 0}; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); // 打印整个画布 for (int i = 0; i < HEIGHT; ++i) { for (int j = 0; j < WIDTH; ++j) { cout << canvas[i][j]; } cout << endl; // 换行 } // 打印游戏信息 cout << "Score: " << score << " | Speed: " << speed << " (Lower is faster)" << endl; cout << "Press 'x' to quit." << endl; }

这里用SetConsoleCursorPosition将光标移回原点再打印,比用system("cls")清屏要流畅,且不会闪屏。DrawSnakeAndFood()函数先清空内部区域,再根据当前的snake数组和food坐标更新画布。注意绘制蛇身的循环,它按照从tailhead的顺序遍历环形数组。

4.3 输入处理函数:Input()

游戏需要实时响应键盘输入来改变蛇的方向。我们用_kbhit()来非阻塞地检测是否有按键按下。

void Input() { if (_kbhit()) { // 如果有按键被按下 char key = _getch(); // 获取按键,不需要回车 switch (key) { case 'w': case 'W': if (dir != DOWN) dir = UP; break; case 's': case 'S': if (dir != UP) dir = DOWN; break; case 'a': case 'A': if (dir != RIGHT) dir = LEFT; break; case 'd': case 'D': if (dir != LEFT) dir = RIGHT; break; case 'x': case 'X': isGameOver = true; break; // 可以添加空格键暂停功能 // case ' ': isPaused = !isPaused; break; } } }

这里有一个非常重要的细节:在判断方向改变时,我们加了限制条件if (dir != DOWN)。这是为了防止蛇直接掉头。比如蛇正在向右移动,你瞬间按下左键,如果没有这个限制,蛇头会立刻向左,导致蛇身“撞”到自己,游戏瞬间结束。这个限制保证了蛇只能转弯,不能原地180度回头。

4.4 游戏逻辑函数:Logic()

这是游戏最核心的部分,包含了移动、吃食物、碰撞检测等所有规则。

void Logic() { if (dir == STOP) return; // 如果方向是STOP,不移动 // 1. 计算新的蛇头位置 Point newHead = snake[head]; switch (dir) { case UP: newHead.y--; break; case DOWN: newHead.y++; break; case LEFT: newHead.x--; break; case RIGHT: newHead.x++; break; } // 2. 碰撞检测 // 2.1 撞墙检测 if (newHead.x <= 0 || newHead.x >= WIDTH - 1 || newHead.y <= 0 || newHead.y >= HEIGHT - 1) { isGameOver = true; return; } // 2.2 撞自身检测(遍历蛇身,从tail到head-1) for (int i = tail; i != head; i = (i + 1) % (WIDTH * HEIGHT)) { if (newHead.x == snake[i].x && newHead.y == snake[i].y) { isGameOver = true; return; } } // 3. 吃食物检测 if (newHead.x == food.x && newHead.y == food.y) { // 吃到食物,蛇长度增加,分数增加 score += 10; snakeLength++; // 生成新的食物 GenerateFood(); // 可以随着分数增加而提高速度 // if (score % 50 == 0 && speed > 20) speed -= 10; } else { // 没吃到食物,蛇尾需要移动(即tail指针前移,覆盖掉旧的蛇尾) tail = (tail + 1) % (WIDTH * HEIGHT); } // 4. 移动蛇头:将新的蛇头位置存入数组,并更新head指针 head = (head + 1) % (WIDTH * HEIGHT); snake[head] = newHead; }

逻辑分解:

  1. 计算新蛇头:根据当前方向dir,算出蛇头下一步的坐标newHead
  2. 碰撞检测
    • 撞墙:判断newHead是否碰到了画布边界('#')。
    • 撞自己:遍历当前蛇身(注意不包括当前的head,因为那是旧蛇头),检查newHead是否与任何一节身体重合。
    • 发生碰撞,游戏结束。
  3. 吃食物检测:如果newHead和食物坐标重合,则:
    • 加分。
    • 蛇长snakeLength加1。注意:这里我们只增加了长度变量,并没有立刻在数组里添加新节点。因为吃食物后,蛇尾不动(不执行else里的tail移动),所以下一次移动时,新的蛇头占据了新位置,而蛇尾还在原地,自然就“变长”了一节。
    • 调用GenerateFood()在空地生成新食物。
  4. 移动执行
    • 如果没吃到食物,蛇尾指针tail需要向前移动一格(环形数组),这意味着旧的蛇尾位置被“遗弃”,将在下一次绘制时被覆盖为空地。
    • 无论是否吃到食物,新的蛇头坐标newHead都要存入数组,并且蛇头指针head向前移动一格,指向这个新位置。

这个利用环形数组和头尾指针的移动逻辑,是理解贪吃蛇算法的关键。它高效地模拟了队列的“先进先出”。

4.5 食物生成函数:GenerateFood()

这个函数需要在画布内部的空地随机生成一个食物,且不能生成在蛇身上。

void GenerateFood() { bool onSnake; do { onSnake = false; // 在[1, WIDTH-2]和[1, HEIGHT-2]范围内生成随机坐标 food.x = rand() % (WIDTH - 2) + 1; food.y = rand() % (HEIGHT - 2) + 1; // 检查是否与蛇身重叠 for (int i = tail; i != head; i = (i + 1) % (WIDTH * HEIGHT)) { if (food.x == snake[i].x && food.y == snake[i].y) { onSnake = true; break; } } // 还要检查是否与蛇头重叠(虽然概率极低) if (food.x == snake[head].x && food.y == snake[head].y) { onSnake = true; } } while (onSnake); // 如果重叠了,就重新生成 }

这里用了一个do...while循环,确保生成的食物一定在空地上。这是一个简单的“拒绝采样”方法。在蛇很长时,这个循环可能会多跑几次,但对于这个小游戏来说完全够用。

5. 主循环与游戏流程整合

所有零件都准备好了,现在把它们组装起来,形成游戏的主循环。

int main() { // ... 之前的环境设置代码(设置标题、隐藏光标等) Init(); // 游戏初始化 while (!isGameOver) { Draw(); // 绘制当前帧 Input(); // 处理用户输入 Logic(); // 更新游戏逻辑 Sleep(speed); // 控制游戏速度,制造动画帧间隔 } // 游戏结束处理 system("cls"); // 清屏,也可以用之前的API方法 cout << "Game Over!" << endl; cout << "Your final score is: " << score << endl; cout << "Press any key to exit..."; _getch(); // 等待一个按键 return 0; }

主循环while (!isGameOver)是游戏的心脏。每一帧(一次循环)都按照“绘制 -> 输入 -> 逻辑更新 -> 等待”的顺序执行。Sleep(speed)函数让程序暂停指定的毫秒数,这决定了蛇的移动速度。speed值越小,循环越快,蛇移动得也越快,游戏难度越高。

6. 完整源码与深度优化建议

将上述所有代码模块按顺序整合到main.cpp中,你就得到了一个完整的、可编译运行的贪吃蛇游戏。由于篇幅,完整代码在此不逐行展开,但结构已完全清晰。

当你成功运行后,可能会觉得有点“简陋”或“卡顿”。这里提供几个深度优化和扩展的方向,让你的贪吃蛇更上一层楼:

6.1 性能优化:局部刷新

我们之前的Draw()函数每次都重绘整个屏幕(80*25=2000个字符),效率低且可能闪烁。优化方法是只重绘发生变化的位置。我们需要记录上一帧画布的状态,或者直接计算哪些坐标需要更新。

  1. 双缓冲:在内存中维护两个画布,一个“后台画布”用于逻辑计算,一帧结束后,将其与“前台画布”比较,只将不同的字符输出到屏幕。这需要更精细的坐标记录。
  2. 脏矩形:记录蛇头、旧蛇头、蛇尾、食物等关键坐标的变化,只更新这几个点。这是控制台小游戏最常用的优化手段。你可以修改DrawSnakeAndFoodDraw函数,不再清空整个内部区域,而是:
    • 在移动前,将旧的蛇尾位置(snake[tail])输出为空格。
    • 将新的蛇头位置(newHead)输出为'@'
    • 将旧的蛇头位置(移动前的snake[head])输出为'*'(如果蛇长度大于1)。
    • 食物被吃后,在旧食物位置输出空格,在新食物位置输出'$'
    • 边界只需要绘制一次。这样每一帧只输出几个字符,极其流畅。

6.2 功能扩展

  1. 暂停功能:在主循环中增加一个bool isPaused变量。在Input()函数里监听空格键,切换暂停状态。在Logic()函数开头,如果isPaused为真则直接返回。
  2. 难度分级:将speedscore关联。例如,每得50分,speed减少10毫秒(但设置一个下限,比如20毫秒)。
  3. 关卡设计:不止是空矩形。可以在Init()函数里,在canvas上画一些固定的障碍物'X',然后在Logic()的碰撞检测中增加对障碍物的判断。
  4. 图形化界面:如果你不满足于字符界面,可以学习简单的图形库,如EasyX(针对VC)、SDL或SFML。将canvas从字符数组变为像素数组,用图形函数绘制方块,原理完全一样。
  5. 存档/读档:将当前的snake数组、snakeLengthheadtailfoodscoredir等所有状态变量写入一个文件。下次游戏时从文件读取并恢复。

6.3 常见编译与运行问题

  1. **conio.h_kbhit()/_getch()报错**:确保你使用的是Windows下的Dev-C++(TDM-GCC/MinGW)。这些函数不是标准C++库,是Windows/MS-DOS的扩展。如果在其他平台(如Linux的GCC)编译,需要换用ncurses`等库。
  2. Sleep()函数报错Sleep()函数在windows.h中,首字母S大写。注意包含头文件。
  3. 游戏速度太快或太慢:调整主循环中的Sleep(speed)参数。speed是毫秒数。
  4. 控制台窗口一闪而过:这通常是因为程序正常结束。确保你的main函数最后有_getch()system("pause")等待用户输入,或者直接在Dev-C++中按F11编译运行(而不是Ctrl+F10),它会自动暂停。
  5. 蛇的移动方向不受控制或反向:重点检查Input()函数中的方向限制逻辑(if (dir != OPPOSITE_DIR)),以及Logic()函数中计算newHead的switch-case是否正确。

从零开始实现一个贪吃蛇,最大的收获不是代码本身,而是这个过程中对程序状态管理、循环控制、数据结构应用和问题分解能力的锻炼。当你看到自己写的蛇在屏幕上按照你的指令游走时,那种成就感是看再多教程都无法替代的。试着去修改它,优化它,加入你自己的创意,这才是编程乐趣的开始。