1. 为什么选贪吃蛇作为入门游戏编程的第一课
1.1 贪吃蛇是游戏编程的"最小可行产品"
很多刚学完C++语法的人都会卡在同一个地方:语法书翻完了,循环、数组、函数、指针都认识了,但打开IDE却不知道能写什么。这种"知识在手、项目没有"的状态特别难受。贪吃蛇就是打破这个僵局的最佳选择,没有之一。
为什么这么说?因为贪吃蛇本质上是一个状态机加渲染循环的极简模型。它包含了游戏编程的所有核心要素:有一个游戏世界(二维网格),有玩家控制的实体(蛇),有游戏规则(吃食物变长、撞墙或撞自己就结束),有输入处理(方向键),有状态更新(每帧移动),有渲染输出(画出蛇和食物)。麻雀虽小,五脏俱全。
对比一下其他常见的入门项目:计算器只涉及输入输出和简单逻辑,没有实时循环的概念;通讯录管理系统主要是数据结构操作,没有图形渲染;而贪吃蛇能让你第一次体会到"游戏循环"这个游戏编程最核心的概念。你写完之后,再去看任何2D游戏引擎的架构,都会觉得似曾相识。
1.2 这个项目适合谁,能解决什么问题
这个项目适合已经掌握C++基础语法的初学者。具体来说,你需要知道什么是变量、if-else、for/while循环、数组、函数、结构体或类。如果你连#include <iostream>都还没写过,那建议先补一下基础再来。
写完之后你能获得什么?第一,你会理解游戏循环的工作原理,知道什么叫"帧"、什么叫"帧率"、为什么游戏需要不断刷新。第二,你会掌握用二维数组表示游戏世界的方法,这个思路可以迁移到推箱子、扫雷、俄罗斯方块等几乎所有网格类游戏。第三,你会学会处理键盘输入,知道怎么让程序实时响应按键而不是傻等回车。第四,你会接触到碰撞检测的基本思路,这是所有动作游戏的基石。
我见过太多人学完C++后直接去啃Unity或Unreal,结果被引擎的复杂度和各种概念淹没,最后放弃。贪吃蛇这种"裸写"的项目反而能让你把底层逻辑吃透,之后再用引擎就是事半功倍。
1.3 技术选型:为什么用控制台而不是图形库
这里要做一个关键决策:是用控制台字符画蛇,还是用EasyX、SDL、SFML这类图形库?
我的建议是第一版一定用控制台。原因有三:第一,控制台不需要额外安装任何库,Windows下用<conio.h>和<windows.h>就能搞定输入和光标控制,Linux/Mac下用<termios.h>和ANSI转义序列也能实现,零依赖。第二,控制台版本能让你把全部精力放在游戏逻辑上,而不是纠结于"怎么加载一张图片""怎么创建窗口"这些和游戏核心无关的事情上。第三,控制台版本的代码量小,通常两三百行就能跑起来,你能在一两天内看到成果,这种正反馈对初学者特别重要。
等你把控制台版本写通了,再迁移到图形库就是换个渲染层的事,游戏逻辑几乎不用动。这个学习路径我强烈推荐。
注意:Windows下的
<conio.h>提供的_kbhit()和_getch()是微软特有的,不是标准C++。如果你追求跨平台,需要用条件编译或者改用其他方案。这个后面会详细讲。
2. 核心机制拆解:贪吃蛇到底在做什么
2.1 游戏世界的数学建模
贪吃蛇的游戏世界可以抽象成一个二维网格。假设我们用一个20行、30列的网格,那么每个格子有两种状态:空着,或者被蛇占据。食物也占据一个格子。
用C++表示就是:
const int WIDTH = 30; const int HEIGHT = 20; int world[HEIGHT][WIDTH]; // 0表示空,1表示蛇身,2表示食物但实际写的时候,我们通常不会真的去遍历这个二维数组来画图,而是用更直接的方式:蛇用一个坐标数组表示,食物用一个坐标表示。二维数组更多是概念上的模型,帮助我们理解碰撞检测的逻辑。
蛇的数据结构可以这样设计:
struct Point { int x; int y; }; Point snake[100]; // 蛇身坐标数组,snake[0]是蛇头 int snakeLength = 3; // 初始长度这里用固定大小的数组而不是std::vector,是为了简化初学者的理解。等你熟练了,换成std::deque会更优雅,因为蛇的移动本质上是"头部加一节、尾部减一节"的队列操作。
2.2 游戏循环:心跳一样的节奏
游戏循环是整个程序的心脏。它的基本结构是:
while (gameRunning) { handleInput(); // 处理键盘输入 updateGame(); // 更新游戏状态 render(); // 渲染画面 sleep(100); // 控制速度 }这个循环每秒执行大约10次(因为sleep 100毫秒),也就是10帧每秒。对于贪吃蛇来说这个速度刚好,太快了人反应不过来,太慢了蛇像蜗牛。
这里有个关键点:输入处理不能阻塞。如果你用std::cin >> key,程序会停在那里等用户按回车,整个游戏就卡死了。所以必须用非阻塞的输入检测,这就是为什么Windows下要用_kbhit()。
_kbhit()的作用是"检测是否有按键被按下",有就返回非零值,没有就返回0,它不会等待。配合_getch()读取按键值,就能实现实时响应。
2.3 蛇的移动算法:队列思想的体现
蛇的移动看起来简单,但里面有个容易踩的坑。很多人第一反应是"把每一节蛇身往前挪一格",但这样写起来很麻烦,而且容易出错。
正确的思路是队列操作:蛇头往当前方向前进一格,变成新的蛇头;蛇尾去掉一格。如果吃到了食物,蛇尾就不去掉,这样蛇就变长了。
用代码表示:
// 计算新蛇头位置 Point newHead = snake[0]; switch (direction) { case UP: newHead.y--; break; case DOWN: newHead.y++; break; case LEFT: newHead.x--; break; case RIGHT: newHead.x++; break; } // 所有蛇身往后移一位(从尾到头) for (int i = snakeLength - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0] = newHead; // 如果没吃到食物,去掉蛇尾 if (!ateFood) { snakeLength--; // 实际上是通过长度控制,数组内容不用清 }等等,这里有个细节:如果没吃到食物,蛇的长度不变,但蛇头前进了一格,所以蛇尾那一格自然就被"挤掉"了。上面的写法中,snakeLength--之后再snakeLength++(如果吃到食物)就实现了这个逻辑。更清晰的写法是:
// 先移动 for (int i = snakeLength - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0] = newHead; // 检查是否吃到食物 if (newHead.x == food.x && newHead.y == food.y) { snakeLength++; // 变长 generateFood(); // 生成新食物 }这样写更直观:蛇头前进,蛇身跟着挪,如果吃到食物就增加长度(相当于尾巴不动),否则长度不变(相当于尾巴前移)。
2.4 碰撞检测:三种死亡方式
贪吃蛇的碰撞检测有三种情况:
撞墙:蛇头的x或y超出边界。这个最简单,一个if判断就行。
撞自己:蛇头和蛇身的某一节重合。注意要从第1节开始检查(第0节是蛇头自己),通常从第4节开始检查更合理,因为蛇头不可能立刻撞到紧挨着的几节。
吃到食物:蛇头和食物坐标重合。
// 撞墙检测 if (newHead.x < 0 || newHead.x >= WIDTH || newHead.y < 0 || newHead.y >= HEIGHT) { gameOver = true; } // 撞自己检测 for (int i = 1; i < snakeLength; i++) { if (newHead.x == snake[i].x && newHead.y == snake[i].y) { gameOver = true; break; } }这里有个经验:撞自己的检测范围可以优化。实际上蛇头只可能撞到蛇身中"转弯处"的那些节点,但为了简单,全遍历也没问题,毕竟蛇最长也就几十节,性能完全不是问题。
3. 从零开始:完整实操流程
3.1 环境准备与项目骨架
我假设你用的是Windows + Visual Studio或者MinGW,这是大多数初学者的环境。Linux/Mac用户需要把输入部分换成termios,后面会提。
首先创建一个空项目,新建一个snake.cpp文件。需要的头文件:
#include <iostream> #include <conio.h> // Windows下用于_kbhit和_getch #include <windows.h> // 用于控制光标位置 #include <cstdlib> // 用于rand和srand #include <ctime> // 用于time如果你用的是Linux或Mac,<conio.h>和<windows.h>都不存在,需要用<termios.h>和<unistd.h>自己实现kbhit()和getch()。为了简化,本文以Windows为例,Linux的替代方案在常见问题部分会讲。
定义常量和全局变量:
const int WIDTH = 30; const int HEIGHT = 20; struct Point { int x, y; }; Point snake[100]; int snakeLength; Point food; int direction; // 0上 1下 2左 3右 bool gameOver; int score;3.2 初始化:把游戏世界摆好
初始化函数要做几件事:设置蛇的初始位置(通常在中间),设置初始方向,生成第一个食物,重置分数。
void initGame() { gameOver = false; direction = 3; // 初始向右 snakeLength = 3; score = 0; // 蛇初始位置在中间,水平排列 int startX = WIDTH / 2; int startY = HEIGHT / 2; snake[0] = {startX, startY}; snake[1] = {startX - 1, startY}; snake[2] = {startX - 2, startY}; srand(time(0)); // 随机种子 generateFood(); }generateFood()要确保食物不会生成在蛇身上:
void generateFood() { do { food.x = rand() % WIDTH; food.y = rand() % HEIGHT; } while (isOnSnake(food)); } bool isOnSnake(Point p) { for (int i = 0; i < snakeLength; i++) { if (snake[i].x == p.x && snake[i].y == p.y) { return true; } } return false; }这里用do-while循环反复随机直到找到空位。理论上如果蛇占满了整个网格会死循环,但实际游戏中蛇不可能占满,所以不用太担心。严谨一点可以加个计数器,尝试100次还找不到就结束游戏(通关了)。
3.3 输入处理:让蛇听你的话
输入处理的关键是不能反向。如果蛇正在向右走,你按左键,蛇不能立刻掉头撞死自己,这个输入应该被忽略。
void handleInput() { if (_kbhit()) { int key = _getch(); switch (key) { case 'w': case 'W': case 72: // 上 if (direction != 1) direction = 0; break; case 's': case 'S': case 80: // 下 if (direction != 0) direction = 1; break; case 'a': case 'A': case 75: // 左 if (direction != 3) direction = 2; break; case 'd': case 'D': case 77: // 右 if (direction != 2) direction = 3; break; } } }这里同时支持了WASD和方向键。方向键的键值是扩展码,_getch()第一次返回224(或0),第二次才返回实际键值。上面代码里72、80、75、77是方向键的扫描码,但直接这样写其实有问题,因为_getch()会先返回224。正确的处理是:
int key = _getch(); if (key == 224 || key == 0) { key = _getch(); // 再读一次 switch (key) { case 72: if (direction != 1) direction = 0; break; // 上 case 80: if (direction != 0) direction = 1; break; // 下 case 75: if (direction != 3) direction = 2; break; // 左 case 77: if (direction != 2) direction = 3; break; // 右 } } else { switch (key) { case 'w': case 'W': if (direction != 1) direction = 0; break; case 's': case 'S': if (direction != 0) direction = 1; break; case 'a': case 'A': if (direction != 3) direction = 2; break; case 'd': case 'D': if (direction != 2) direction = 3; break; } }这个细节很多教程都不讲,导致读者照着写发现方向键没用。我当初就在这里卡了半天。
3.4 渲染:用字符画出游戏画面
控制台渲染的核心是光标定位。Windows下用SetConsoleCursorPosition,配合GetStdHandle(STD_OUTPUT_HANDLE)获取控制台句柄。
void gotoxy(int x, int y) { COORD coord; coord.X = x; coord.Y = y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); }然后渲染函数:
void render() { // 画上边界 gotoxy(0, 0); for (int i = 0; i < WIDTH + 2; i++) std::cout << "#"; // 画每一行 for (int y = 0; y < HEIGHT; y++) { gotoxy(0, y + 1); std::cout << "#"; // 左边界 for (int x = 0; x < WIDTH; x++) { if (x == snake[0].x && y == snake[0].y) { std::cout << "O"; // 蛇头 } else if (isOnSnake({x, y})) { std::cout << "o"; // 蛇身 } else if (x == food.x && y == food.y) { std::cout << "*"; // 食物 } else { std::cout << " "; } } std::cout << "#"; // 右边界 } // 画下边界 gotoxy(0, HEIGHT + 1); for (int i = 0; i < WIDTH + 2; i++) std::cout << "#"; // 显示分数 gotoxy(0, HEIGHT + 2); std::cout << "Score: " << score << " "; }这里用O表示蛇头,o表示蛇身,*表示食物,#表示墙。每次渲染都重画整个画面,因为控制台刷新很快,看不出闪烁。如果觉得闪,可以用双缓冲,但控制台版本没必要。
提示:
gotoxy的坐标是相对于控制台窗口的,不是相对于游戏世界的。所以画边界的时候要留出偏移。上面的代码里,游戏世界从(1,1)开始,边界在(0,0)到(WIDTH+1, HEIGHT+1)。
3.5 主循环:把所有东西串起来
主循环的节奏控制很关键。如果不用sleep,循环会跑满CPU,蛇会飞一样快。用Sleep(100)让每帧间隔100毫秒,蛇的速度就合适了。
int main() { initGame(); while (!gameOver) { handleInput(); // 计算新蛇头 Point newHead = snake[0]; switch (direction) { case 0: newHead.y--; break; case 1: newHead.y++; break; case 2: newHead.x--; break; case 3: newHead.x++; break; } // 撞墙检测 if (newHead.x < 0 || newHead.x >= WIDTH || newHead.y < 0 || newHead.y >= HEIGHT) { gameOver = true; break; } // 撞自己检测 for (int i = 0; i < snakeLength; i++) { if (newHead.x == snake[i].x && newHead.y == snake[i].y) { gameOver = true; break; } } if (gameOver) break; // 移动蛇 for (int i = snakeLength - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0] = newHead; // 吃食物检测 if (newHead.x == food.x && newHead.y == food.y) { snakeLength++; score += 10; generateFood(); } render(); Sleep(100); } gotoxy(0, HEIGHT + 4); std::cout << "Game Over! Final Score: " << score << std::endl; system("pause"); return 0; }注意撞自己检测里,我从0开始遍历。实际上蛇头不可能撞到snake[0](自己),但撞到snake[1]是可能的吗?如果蛇长度是3,蛇头向右,蛇身是(5,5)(4,5)(3,5),蛇头移到(6,5),不会撞到任何一节。但如果蛇转了个弯,比如蛇身是(5,5)(5,4)(4,4),蛇头从(5,5)向上到(5,4),那就撞到snake[1]了。所以从1开始检查是对的,从0开始也无妨(因为newHead和snake[0]比较时,snake[0]还是旧蛇头,可能相等也可能不等,但逻辑上不应该算撞自己)。
更严谨的做法是:先移动蛇,再检测新蛇头是否和蛇身(除了蛇头自己)重合。但那样代码顺序要调整。上面的写法是先检测再移动,检测时snake[0]还是旧蛇头,所以从1开始遍历是对的。
4. 常见问题与排查技巧实录
4.1 蛇移动时"拖尾"或"闪烁"怎么办
这是控制台渲染最常见的问题。现象是蛇移动时,旧位置没有完全擦除,留下一串残影。
原因通常是渲染时没有清空整个画面。上面的render()函数每次都会重画所有格子,包括空格子,所以理论上不会有残影。但如果你优化了渲染,只画变化的部分,就容易出问题。
另一个可能的原因是gotoxy的坐标计算错了,导致某些格子没被覆盖到。检查一下边界和循环范围。
还有一种情况是控制台窗口太小,内容换行了。WIDTH=30、HEIGHT=20加上边界和分数行,总共需要约35列、24行。如果控制台窗口小于这个尺寸,就会出现滚动和错位。解决办法是调整窗口大小,或者用system("mode con cols=40 lines=25")设置(仅Windows)。
4.2 按键响应迟钝或不灵敏
如果蛇对按键反应慢半拍,通常是Sleep(100)太长了。可以改成Sleep(50),但蛇会变快。更好的方案是分离输入检测和游戏更新:输入检测每帧都做,但游戏更新按固定间隔。
简单做法是用一个计数器:
int frameCount = 0; while (!gameOver) { handleInput(); // 每帧都检测输入 frameCount++; if (frameCount >= 5) { // 每5帧更新一次游戏 frameCount = 0; updateGame(); render(); } Sleep(20); // 每帧20毫秒,5帧就是100毫秒 }这样输入响应是20毫秒一次,游戏更新是100毫秒一次,手感会好很多。
4.3 食物生成在蛇身上导致无法吃到
这个问题的根源是generateFood()没有正确检查。确保isOnSnake()函数遍历了所有蛇身节点,包括蛇头。
另一个隐蔽的bug是:蛇移动后,旧蛇尾的位置可能被误判为蛇身。因为蛇的长度是通过snakeLength控制的,数组里超出snakeLength的部分是无效数据。isOnSnake()必须用snakeLength作为循环上界,而不是数组大小。
4.4 游戏结束后窗口立刻关闭
system("pause")可以解决,但它不是标准C++,而且会显示"请按任意键继续..."。更优雅的做法是:
std::cout << "Press any key to exit..."; _getch();或者用std::cin.get(),但需要先清空输入缓冲区。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 蛇移动有残影 | 渲染未清空旧位置 | 每帧重画整个画面 |
| 按键无反应 | 用了阻塞式输入 | 改用_kbhit()+_getch() |
| 方向键无效 | 未处理扩展码 | 先读224再读实际键值 |
| 蛇可以反向 | 未做方向限制 | 加if (direction != opposite)判断 |
| 食物在蛇身上 | 生成时未检查 | 用do-while循环直到找到空位 |
| 游戏速度太快 | Sleep时间太短 | 调整Sleep参数或加帧计数器 |
| 窗口错位 | 控制台尺寸不够 | 调整窗口大小或减小游戏区域 |
| 分数不更新 | 忘记加分 | 吃到食物时score += 10 |
4.6 几个我踩过的坑
坑一:_getch()的返回值类型。_getch()返回的是int,不是char。如果你用char key = _getch(),方向键的224会被截断成-32,导致判断失败。一定要用int接收。
坑二:rand()的随机性。如果不调用srand(time(0)),每次运行的食物位置都一样。但srand只需要调用一次,放在initGame()里就行,不要放在循环里。
坑三:蛇的初始方向。如果初始方向是向右,但蛇的初始位置太靠右,第一帧就会撞墙。确保初始位置在中间,并且有足够的空间。
坑四:数组越界。蛇的最大长度是WIDTH*HEIGHT,但数组只开了100。如果蛇长超过100,就会越界写入,导致各种诡异bug。要么把数组开大(比如WIDTH*HEIGHT),要么用std::vector动态扩容。
坑五:Linux下的输入处理。如果你在Linux下开发,<conio.h>不存在。需要用termios实现kbhit():
#include <termios.h> #include <unistd.h> #include <fcntl.h> int kbhit() { struct termios oldt, newt; int ch; int oldf; tcgetattr(STDIN_FILENO, &oldt); newt = oldt; newt.c_lflag &= ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, &newt); oldf = fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); ch = getchar(); tcsetattr(STDIN_FILENO, TCSANOW, &oldt); fcntl(STDIN_FILENO, F_SETFL, oldf); if (ch != EOF) { ungetc(ch, stdin); return 1; } return 0; }这个函数每次调用都会修改终端设置再恢复,效率不高但能用。更好的做法是在程序开始时设置一次,结束时恢复。
5. 进阶优化与扩展思路
5.1 用面向对象重构代码
上面的代码是面向过程的,所有数据都是全局变量。当你熟悉之后,可以重构成类:
class SnakeGame { private: Point snake[100]; int snakeLength; Point food; int direction; bool gameOver; int score; public: void init(); void run(); private: void handleInput(); void update(); void render(); void generateFood(); bool isOnSnake(Point p); };这样封装性更好,也更容易扩展。比如你想加一个"双人模式",只需要实例化两个SnakeGame对象,或者把蛇的数据抽出来做成独立的Snake类。
5.2 增加游戏功能
基础版跑通后,可以加这些功能:
难度选择:开始前让玩家选择速度,用不同的Sleep值实现。
障碍物:在网格中随机生成一些固定障碍,撞到也游戏结束。
特殊食物:偶尔生成一个"金苹果",吃了加更多分或者暂时加速。
穿墙模式:撞墙不死亡,而是从对面穿出来。这个只需要修改碰撞检测逻辑。
最高分记录:把最高分写入文件,下次启动时读取。
暂停功能:按空格暂停,再按继续。
这些功能每一个都不难,但能让你对游戏编程的理解更深一层。
5.3 迁移到图形界面
当你觉得控制台版本已经玩腻了,可以尝试用EasyX(Windows)或SDL(跨平台)重写渲染层。游戏逻辑几乎不用改,只需要把render()函数里的字符输出换成图形绘制。
以EasyX为例:
#include <graphics.h> void render() { cleardevice(); // 画蛇 for (int i = 0; i < snakeLength; i++) { int left = snake[i].x * CELL_SIZE; int top = snake[i].y * CELL_SIZE; if (i == 0) { setfillcolor(GREEN); } else { setfillcolor(LIGHTGREEN); } fillrectangle(left, top, left + CELL_SIZE, top + CELL_SIZE); } // 画食物 setfillcolor(RED); int fx = food.x * CELL_SIZE; int fy = food.y * CELL_SIZE; fillcircle(fx + CELL_SIZE/2, fy + CELL_SIZE/2, CELL_SIZE/2 - 2); }CELL_SIZE可以设为20像素,这样30x20的网格就是600x400的窗口,大小合适。
5.4 性能优化的思考
控制台版本的贪吃蛇性能完全不是问题,但如果你迁移到图形界面,或者把网格扩大到100x100,就需要考虑优化了。
渲染优化:不要每帧重画所有格子,只重画变化的部分。比如蛇移动时,只有蛇头新增的格子和蛇尾消失的格子需要更新。
碰撞检测优化:撞自己检测是O(n)的,n是蛇长。如果蛇很长,可以用一个二维bool数组记录哪些格子有蛇,这样检测就是O(1)。但维护这个数组也有开销,需要权衡。
输入缓冲:如果玩家快速按多个方向键,可能会在一帧内积累多个输入。可以用一个队列缓存输入,每帧只处理一个。
这些优化在贪吃蛇这个规模下其实没必要,但思考这些问题能让你对游戏编程有更深的理解。
5.5 代码风格与可维护性建议
最后说几个写代码的习惯,这些在初学阶段养成会受益终身:
常量用const或constexpr:不要到处写魔法数字,把WIDTH、HEIGHT、初始长度这些定义成常量。
函数职责单一:一个函数只做一件事。updateGame()就只更新状态,不要在里面渲染。
命名有意义:snakeLength比len好,generateFood比gen好。代码是写给人看的,顺便给机器执行。
注释解释为什么,而不是做什么:// 蛇头前进一格这种注释没意义,代码本身就说清楚了。// 从1开始遍历,因为snake[0]是蛇头自己这种注释才有价值。
版本控制:哪怕是一个人写的小项目,也用Git管理起来。每次加一个新功能就提交一次,出问题了可以回滚。
我个人在实际操作中的体会是,贪吃蛇这个项目虽然简单,但每次重写都能发现新的东西。第一次写可能只关注"能跑起来",第二次会想"怎么让代码更清晰",第三次会琢磨"怎么加新功能而不破坏原有结构"。这种迭代的过程,比一次性写一个完美版本更有价值。如果你能把控制台版本写三遍,每一遍都有改进,那你的C++功底和游戏编程思维都会上一个台阶。