☰
C++贪吃蛇游戏编程入门:从零实现控制台版
2026/10/16 3:55:50 网站建设 项目流程

1. 为什么贪吃蛇是C++入门游戏编程的最佳练手项目

很多人学完C++的基础语法之后,都会卡在一个尴尬的阶段:语法书翻完了,循环、数组、函数、类都认识,但让自己从零写一个能跑起来的东西,脑子里一片空白。这个阶段最需要的不是继续啃更厚的书,而是找一个规模适中、逻辑闭环、能立刻看到画面反馈的小项目来练手。贪吃蛇就是这样一个几乎完美的选择。

我见过太多初学者一上来就想做五子棋、俄罗斯方块甚至RPG小游戏,结果卡在图形界面或者复杂的碰撞逻辑上,三天就放弃了。贪吃蛇的好处在于:它的核心逻辑用一维数组或者二维数组就能描述清楚,不需要任何图形库也能在控制台里跑起来,而且它的游戏循环、状态更新、输入处理这套骨架,和后面做任何游戏都是相通的。你把这一个项目吃透,等于把游戏编程最基础的思维方式建立起来了。

这篇文章我会带你从零开始,用C++写一个能在控制台运行的贪吃蛇。不依赖任何第三方图形库,纯标准库实现,Windows和Linux都能编译。我会把每一步的设计理由讲清楚,包括为什么用数组而不是链表、为什么用Sleep控制帧率、蛇身移动的坐标更新为什么要从尾到头遍历。这些细节才是真正决定你能不能独立写出第二个、第三个游戏的关键。

适合谁看:学过C++基础语法(变量、循环、数组、函数、结构体或类),但没做过完整项目的朋友。如果你连for循环和数组下标都还不熟,建议先把这些补上再回来。整篇内容我会尽量用大白话解释,代码给全,注释给足,你照着敲一遍就能跑。

2. 动手之前先把游戏循环这件事想明白

2.1 游戏本质上就是一个死循环

初学者最容易忽略的一个概念是:游戏程序的核心结构就是一个不停转的循环。你看到的每一帧画面、每一次蛇的移动、每一个按键的响应,都是这个循环转一圈的结果。这个循环通常叫"游戏主循环"(Game Loop),它的基本节奏是这样的:

  1. 处理玩家输入(有没有按键)
  2. 更新游戏状态(蛇往哪走、吃到食物没有、撞墙没有)
  3. 渲染画面(把当前状态画到屏幕上)
  4. 等待一小段时间(控制游戏速度)
  5. 回到第1步

贪吃蛇也不例外。你只要把这个循环搭起来,剩下的就是往每个环节里填逻辑。很多人写游戏写不下去,就是因为脑子里没有这个循环的框架,东写一块西写一块,最后逻辑全乱。

2.2 控制台游戏为什么还需要"渲染"

有人会问:控制台不就是cout输出文字吗,哪来的渲染?其实控制台游戏一样需要渲染,只不过渲染的方式是"清屏+重画"。每一帧我们都把整个游戏画面重新打印一遍,然后清掉上一帧的内容,视觉上就形成了动画效果。

在Windows下可以用system("cls")清屏,Linux下用system("clear")。但system调用开销比较大,画面会闪。更优雅的做法是用ANSI转义序列把光标移回左上角,然后覆盖重画,这样几乎不闪。不过为了代码的跨平台和简单,入门阶段用system("cls")完全够用,等你熟练了再优化。

2.3 帧率控制:为什么不能让它全速跑

如果你不加任何延时,游戏循环会以CPU能跑的最快速度疯狂转圈,蛇会瞬间飞出去撞墙,玩家根本来不及反应。所以必须有一个"节拍器"来控制游戏速度。最简单的方式是调用Sleep函数(Windows下是Sleep(毫秒),Linux下是usleep(微秒))。

这里有个经验值:贪吃蛇的每帧间隔设在100到200毫秒之间比较舒服。太快了反应不过来,太慢了蛇像蜗牛。我一般用150毫秒起步,随着分数增加逐渐减小间隔来提升难度,这就是所谓的"动态难度"。

// 跨平台的延时封装 #ifdef _WIN32 #include <windows.h> #define SLEEP(ms) Sleep(ms) #else #include <unistd.h> #define SLEEP(ms) usleep((ms) * 1000) #endif

这段宏定义很实用,写一次以后所有项目都能复用。注意Linux的usleep参数是微秒,所以要乘1000。

3. 数据结构选型:蛇身到底该用什么存

3.1 数组方案和链表方案的取舍

蛇身本质上是一串有序的坐标点。存这串坐标,最直觉的两种选择是数组和链表。很多教程一上来就用链表,理由是"蛇头插入、蛇尾删除,链表是O(1)"。这话理论上没错,但对初学者来说,链表方案有几个坑:

  • 需要手写节点结构体和指针操作,容易出内存泄漏
  • 调试的时候没法直接print整个蛇身,得遍历
  • 代码量翻倍,逻辑复杂度上升

而数组方案呢?蛇的最大长度是有限的(受地图大小限制),我们完全可以用一个固定大小的数组来存蛇身坐标,用一个变量记录当前长度。蛇移动的时候,本质上是"头部长出一个新坐标,尾部删掉一个旧坐标",用数组实现就是整体挪动或者用环形缓冲。

我的建议是:入门阶段用数组,等你能熟练写出数组版本之后,再自己改写成链表版本作为进阶练习。这样你能亲身体会两种数据结构的差异,比直接抄链表代码收获大得多。

3.2 坐标点的表示

蛇身的每个节点就是一个(x, y)坐标。用一个简单的结构体表示最清晰:

struct Point { int x; int y; };

然后蛇身就是一个Point数组:

const int MAX_SNAKE_LEN = 100; Point snake[MAX_SNAKE_LEN]; int snakeLength = 3; // 初始长度3节

这里MAX_SNAKE_LEN取100是个保守值,实际能到多长取决于地图大小。如果你的地图是20x20,那蛇最长也就400节,取100够用了。想更严谨可以取地图格子总数。

3.3 蛇身移动的坐标更新顺序

这是整个项目里最容易写错的一个点,我必须重点讲。蛇移动的时候,你不能简单地让每一节都往前挪一格,因为那样会互相覆盖。正确的做法是从尾巴开始,每一节都变成它前面那一节的坐标:

// 从尾到头遍历,每节追上它前面一节 for (int i = snakeLength - 1; i > 0; i--) { snake[i] = snake[i - 1]; } // 然后单独更新蛇头 snake[0].x += dirX; snake[0].y += dirY;

为什么必须从尾到头?因为如果你从头到尾遍历,snake[1]会先被改成snake[0]的新位置,然后snake[2]又去追snake[1],结果整条蛇会瞬间塌缩成一团。从尾到头就不会有这个问题,因为每一节读取的"前一节"都还没被修改。

这个细节我在第一次写的时候踩过坑,蛇会莫名其妙变成一坨,排查了半天才反应过来是遍历顺序的问题。你如果自己写,一定要记住这个顺序。

4. 地图、食物与碰撞检测的完整实现

4.1 地图边界怎么画

控制台地图最简单的画法是用字符拼一个矩形框。比如20列宽、15行高,四周用#围起来,中间是空格。每次渲染的时候,先打印上边框,然后逐行打印左右边框和中间内容,最后打印下边框。

const int MAP_WIDTH = 20; const int MAP_HEIGHT = 15; void drawMap() { // 上边框 for (int i = 0; i < MAP_WIDTH + 2; i++) cout << '#'; cout << endl; // 中间行 for (int y = 0; y < MAP_HEIGHT; y++) { cout << '#'; for (int x = 0; x < MAP_WIDTH; x++) { // 这里根据(x,y)位置画蛇、食物或空格 } cout << '#' << endl; } // 下边框 for (int i = 0; i < MAP_WIDTH + 2; i++) cout << '#'; cout << endl; }

注意边框占了额外两列,所以实际游戏区域是MAP_WIDTH x MAP_HEIGHT,坐标范围是x: 0~MAP_WIDTH-1,y: 0~MAP_HEIGHT-1。这个坐标系一定要统一,否则碰撞检测会出错。

4.2 食物生成:怎么避免生成到蛇身上

食物就是一个随机坐标。但你不能随便随机,因为有可能随机到蛇身占据的格子上,那样食物就"藏"在蛇里了,玩家永远吃不到。所以生成食物之后必须检查这个位置是否和蛇身重叠,如果重叠就重新生成。

Point generateFood() { Point food; bool valid = false; while (!valid) { food.x = rand() % MAP_WIDTH; food.y = rand() % MAP_HEIGHT; valid = true; for (int i = 0; i < snakeLength; i++) { if (snake[i].x == food.x && snake[i].y == food.y) { valid = false; break; } } } return food; }

这个while循环理论上可能转很多次,但实际游戏中蛇身占比很小,通常一两次就成功了。如果地图特别小、蛇特别长,可以考虑用"收集所有空格再随机选一个"的方式,效率更稳定。

4.3 碰撞检测的三种情况

碰撞检测要处理三种情况,缺一不可:

碰撞类型判断条件处理方式
撞墙蛇头x<0 或 x>=MAP_WIDTH 或 y<0 或 y>=MAP_HEIGHT游戏结束
撞自己蛇头坐标和任意一节蛇身坐标相同游戏结束
吃到食物蛇头坐标和食物坐标相同蛇身加长,分数+1,重新生成食物

撞自己的检测有个细节:只需要检测蛇头是否和蛇身的第1节到第N-1节重叠,不用检测第0节(蛇头自己)。因为蛇头不可能和自己重叠。另外,如果蛇的长度是1,那永远不会撞到自己。

bool checkCollision() { // 撞墙 if (snake[0].x < 0 || snake[0].x >= MAP_WIDTH || snake[0].y < 0 || snake[0].y >= MAP_HEIGHT) { return true; } // 撞自己(从第1节开始检查) for (int i = 1; i < snakeLength; i++) { if (snake[0].x == snake[i].x && snake[0].y == snake[i].y) { return true; } } return false; }

4.4 吃到食物后蛇怎么变长

吃到食物后蛇要加长一节。最简单的做法是:不删除尾巴。正常移动时我们会把尾巴那节"丢掉"(通过坐标覆盖实现),吃到食物时就不丢,这样蛇自然就长了一节。

具体实现上,可以在移动逻辑里加一个判断:如果这一帧吃到了食物,就跳过尾巴的更新,同时snakeLength++。但要注意数组越界,snakeLength不能超过MAX_SNAKE_LEN。

// 移动逻辑(简化版) for (int i = snakeLength - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0].x += dirX; snake[0].y += dirY; // 如果吃到食物 if (snake[0].x == food.x && snake[0].y == food.y) { if (snakeLength < MAX_SNAKE_LEN) { snakeLength++; // 加长,尾巴不动 } score++; food = generateFood(); }

这里有个小技巧:加长的时候,新加的那节坐标其实是上一帧尾巴的坐标,因为我们没有更新它。视觉上看起来就是蛇尾长出了一节,很自然。

5. 输入处理与方向控制的那些坑

5.1 控制台怎么读取方向键

这是控制台游戏最烦人的地方之一。Windows下用_getch()可以读取按键,方向键是特殊键,需要读两次:第一次返回0或224,第二次才返回具体的方向码(上72、下80、左75、右77)。Linux下则要用termios把终端设成非规范模式,然后读getchar(),方向键是ESC序列(\033[A等)。

为了代码简洁,我建议入门阶段用WASD或者方向键的字母替代方案:W上、S下、A左、D右。这样跨平台简单,一行cin或者getchar就能搞定。等你把游戏逻辑跑通了,再花时间研究方向键的读取。

char input; if (_kbhit()) { // Windows下检测是否有按键 input = _getch(); switch (input) { case 'w': case 'W': if (dirY != 1) { dirX = 0; dirY = -1; } break; case 's': case 'S': if (dirY != -1) { dirX = 0; dirY = 1; } break; case 'a': case 'A': if (dirX != 1) { dirX = -1; dirY = 0; } break; case 'd': case 'D': if (dirX != -1) { dirX = 1; dirY = 0; } break; } }

5.2 为什么不能直接反向

注意上面代码里的if判断:当蛇正在向右走时,不能直接按左键让它反向。因为反向会导致蛇头瞬间撞上自己的脖子,游戏立刻结束。这不是bug,是规则。所以每次改变方向前,都要检查新方向是否和当前方向相反,相反就忽略这次输入。

这个判断看起来简单,但如果你忘了写,玩家一按反向键就死,体验极差。我见过不少新手版本都有这个问题。

5.3 输入缓冲和按键丢失

控制台输入还有个坑:如果你用cin >> input,它是阻塞的,游戏循环会卡在等待输入上,蛇就不动了。所以必须用非阻塞的方式检测按键。Windows下_kbhit()配合_getch()是标准做法。Linux下需要自己封装,稍微麻烦一点。

另一个问题是按键缓冲。如果玩家快速按了好几个方向键,这些按键会排队,游戏会依次处理,可能导致蛇走出奇怪的路径。解决办法是每帧只处理一个按键,或者干脆清空缓冲区只取最后一个。入门阶段每帧处理一个就够了。

6. 把代码组装起来:完整可运行版本

6.1 完整代码结构

下面是一个完整的、可以直接编译运行的版本。我把它拆成几个函数,每个函数职责单一,方便你理解和修改。

#include <iostream> #include <cstdlib> #include <ctime> #ifdef _WIN32 #include <windows.h> #include <conio.h> #define CLEAR "cls" #define SLEEP(ms) Sleep(ms) #else #include <unistd.h> #include <termios.h> #include <fcntl.h> #define CLEAR "clear" #define SLEEP(ms) usleep((ms) * 1000) #endif using namespace std; const int MAP_WIDTH = 20; const int MAP_HEIGHT = 15; const int MAX_SNAKE_LEN = 200; struct Point { int x, y; }; Point snake[MAX_SNAKE_LEN]; int snakeLength; Point food; int dirX, dirY; int score; bool gameOver; void initGame() { snakeLength = 3; snake[0] = {MAP_WIDTH / 2, MAP_HEIGHT / 2}; snake[1] = {MAP_WIDTH / 2 - 1, MAP_HEIGHT / 2}; snake[2] = {MAP_WIDTH / 2 - 2, MAP_HEIGHT / 2}; dirX = 1; dirY = 0; score = 0; gameOver = false; srand((unsigned)time(nullptr)); // 生成第一个食物 food.x = rand() % MAP_WIDTH; food.y = rand() % MAP_HEIGHT; } void draw() { system(CLEAR); // 上边框 for (int i = 0; i < MAP_WIDTH + 2; i++) cout << '#'; cout << "\n"; for (int y = 0; y < MAP_HEIGHT; y++) { cout << '#'; for (int x = 0; x < MAP_WIDTH; x++) { bool printed = false; // 先判断是不是蛇头 if (snake[0].x == x && snake[0].y == y) { cout << 'O'; printed = true; } // 再判断是不是蛇身 if (!printed) { for (int i = 1; i < snakeLength; i++) { if (snake[i].x == x && snake[i].y == y) { cout << 'o'; printed = true; break; } } } // 再判断是不是食物 if (!printed && food.x == x && food.y == y) { cout << '*'; printed = true; } if (!printed) cout << ' '; } cout << "#\n"; } // 下边框 for (int i = 0; i < MAP_WIDTH + 2; i++) cout << '#'; cout << "\n"; cout << "Score: " << score << " Length: " << snakeLength << "\n"; cout << "Controls: WASD to move, Q to quit\n"; } void handleInput() { #ifdef _WIN32 if (_kbhit()) { char c = _getch(); switch (c) { case 'w': case 'W': if (dirY != 1) { dirX = 0; dirY = -1; } break; case 's': case 'S': if (dirY != -1) { dirX = 0; dirY = 1; } break; case 'a': case 'A': if (dirX != 1) { dirX = -1; dirY = 0; } break; case 'd': case 'D': if (dirX != -1) { dirX = 1; dirY = 0; } break; case 'q': case 'Q': gameOver = true; break; } } #else // Linux非阻塞读取 struct termios oldt, newt; tcgetattr(STDIN_FILENO, &oldt); newt = oldt; newt.c_lflag &= ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, &newt); int oldf = fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); char c = getchar(); tcsetattr(STDIN_FILENO, TCSANOW, &oldt); fcntl(STDIN_FILENO, F_SETFL, oldf); switch (c) { case 'w': case 'W': if (dirY != 1) { dirX = 0; dirY = -1; } break; case 's': case 'S': if (dirY != -1) { dirX = 0; dirY = 1; } break; case 'a': case 'A': if (dirX != 1) { dirX = -1; dirY = 0; } break; case 'd': case 'D': if (dirX != -1) { dirX = 1; dirY = 0; } break; case 'q': case 'Q': gameOver = true; break; } #endif } void update() { // 移动蛇身 for (int i = snakeLength - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0].x += dirX; snake[0].y += dirY; // 撞墙检测 if (snake[0].x < 0 || snake[0].x >= MAP_WIDTH || snake[0].y < 0 || snake[0].y >= MAP_HEIGHT) { gameOver = true; return; } // 撞自己检测 for (int i = 1; i < snakeLength; i++) { if (snake[0].x == snake[i].x && snake[0].y == snake[i].y) { gameOver = true; return; } } // 吃食物 if (snake[0].x == food.x && snake[0].y == food.y) { if (snakeLength < MAX_SNAKE_LEN) snakeLength++; score++; // 重新生成食物 bool valid = false; while (!valid) { food.x = rand() % MAP_WIDTH; food.y = rand() % MAP_HEIGHT; valid = true; for (int i = 0; i < snakeLength; i++) { if (snake[i].x == food.x && snake[i].y == food.y) { valid = false; break; } } } } } int main() { initGame(); while (!gameOver) { draw(); handleInput(); update(); SLEEP(150); } cout << "\nGame Over! Final Score: " << score << "\n"; return 0; }

6.2 编译和运行

Windows下用MinGW或者Visual Studio直接编译:

g++ snake.cpp -o snake.exe snake.exe

Linux下:

g++ snake.cpp -o snake ./snake

注意Linux下需要链接termios,但它是标准库的一部分,通常不用额外加-l参数。如果编译报错找不到usleep,加上#include <unistd.h>即可。

6.3 实测中容易遇到的问题

问题一:画面闪烁严重。这是system("cls")的通病。解决办法是用ANSI转义序列\033[H把光标移回左上角,然后逐行覆盖。但Windows老版本控制台默认不支持ANSI,需要先调用SetConsoleMode开启虚拟终端处理。入门阶段忍一忍,或者用双缓冲(先拼好整个画面字符串再一次性输出)。

问题二:蛇移动速度不均匀。因为Sleep只是大致延时,实际帧间隔会有波动。如果追求精确,可以用chrono库计算实际经过的时间,做固定时间步长更新。但对贪吃蛇来说没必要,Sleep够用。

问题三:Linux下按键读取有问题。上面的Linux代码每次调用handleInput都会修改终端属性,效率很低。更好的做法是在main开始时设置一次,结束时恢复。我这里为了代码紧凑才这么写,你实际项目里应该优化。

问题四:食物生成死循环。如果蛇长得几乎填满地图,while(!valid)可能转很久。解决办法是维护一个空格列表,或者当蛇长超过地图一半时直接判定胜利。

7. 从能跑到好用:几个值得做的优化方向

7.1 动态难度:越吃越快

固定150毫秒的帧间隔玩久了会腻。一个简单的改进是让延时随分数递减:

int delay = 150 - score * 3; if (delay < 50) delay = 50; // 下限50毫秒 SLEEP(delay);

这样每吃一个食物蛇就快一点,难度自然上升。下限设50毫秒是因为再快人眼和手指就跟不上了。

7.2 分数持久化:把最高分存到文件

每次游戏结束把最高分写到一个文本文件里,下次启动读出来显示。这是最简单的持久化练习,用fstream几行就能搞定:

int loadHighScore() { ifstream fin("highscore.txt"); int hs = 0; if (fin) fin >> hs; return hs; } void saveHighScore(int hs) { ofstream fout("highscore.txt"); fout << hs; }

这个功能虽小,但能让你第一次体会"数据在程序关闭后还能保留"的感觉,是迈向更复杂项目的必经一步。

7.3 用类重构:把游戏封装成一个对象

上面的代码全是全局变量和自由函数,规模小的时候没问题,但一旦要扩展(比如加暂停、加菜单、加多种模式),全局变量就会变成灾难。这时候应该用类把游戏状态封装起来:

class SnakeGame { private: Point snake[MAX_SNAKE_LEN]; int snakeLength; Point food; int dirX, dirY; int score; bool gameOver; public: void init(); void run(); void draw(); void handleInput(); void update(); };

重构之后,main函数就变成三行:创建对象、初始化、运行。这就是面向对象编程的价值——把复杂度关在类里面,外面看到的接口干干净净。我强烈建议你在跑通数组版本之后,亲手做一次这个重构,收获会非常大。

7.4 用链表替代数组:进阶练习

等你对类版本也熟练了,可以尝试把蛇身从数组换成链表。每个节点存坐标和指向下一节的指针,蛇头插入新节点,蛇尾删除节点。这样蛇的长度就不受MAX_SNAKE_LEN限制了。但要注意内存管理,删除节点时记得delete,否则会泄漏。

这个练习的意义不在于链表本身,而在于让你体会"同一个问题用不同数据结构解决"的思维差异。做完之后你对数据结构的理解会上一个台阶。

8. 我在教别人写贪吃蛇时总结的几条经验

带过几个朋友入门之后,我发现大家踩的坑高度集中,这里统一说一下。

第一,不要一上来就追求图形界面。很多人觉得控制台太丑,非要用EasyX或者SDL。结果光配置环境就花了两天,游戏逻辑一行没写,热情先耗光了。控制台版本虽然朴素,但它让你专注在逻辑上,这才是入门阶段最该练的。等你控制台版本写得滚瓜烂熟,换图形库只是换个渲染函数的事。

第二,先把伪代码写出来再敲键盘。我见过太多人打开IDE就开始写,写到一半发现逻辑不对,删了重来。正确做法是先在纸上或者注释里把游戏循环、移动逻辑、碰撞检测的步骤写清楚,确认没问题了再翻译成代码。这个习惯能帮你省下大量调试时间。

第三,善用调试输出。蛇的坐标、方向、长度这些关键变量,在开发阶段可以每帧打印出来看。别嫌乱,等你确认逻辑正确了再删掉。我调试移动逻辑的时候就是靠打印每一节的坐标,才发现遍历顺序写反了。

第四,边界条件一定要测。蛇贴着墙走会怎样?蛇长度是1的时候撞自己检测会不会越界?食物生成在蛇头正前方会怎样?这些边界情况你不主动测,它们就会在某个时刻突然冒出来让你措手不及。养成"写完一个功能就想想边界"的习惯,受益终身。

第五,代码风格从第一天就注意。变量命名要有意义(snakeLength而不是n),函数职责要单一(draw就只管画,别在里面改状态),常量用const而不是魔法数字。这些习惯在项目小的时候看不出差别,但项目一大就是天壤之别。

最后说一句,贪吃蛇这个项目真正的价值不在于游戏本身,而在于它逼着你把C++的数组、循环、函数、结构体、输入输出这些基础知识串起来用一遍。你写完这个项目,再回头看那些语法书,会发现以前觉得抽象的概念突然都具体了。这就是项目驱动学习的威力。写完控制台版本之后,你可以试着加个开始菜单、加个暂停功能、加个排行榜,每一个小扩展都是一次真实的工程练习。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询