很多学C语言的朋友都有个共同的困惑:语法书翻了好几遍,指针、结构体、循环这些都看了,但真到了自己动手写代码的时候,却不知道从哪里开始。贪吃蛇这个项目我印象很深,它是典型的“麻雀虽小,五脏俱全”的练手项目,用一个控制台程序就把C语言里最核心的东西串起来了:数组当蛇身、结构体存坐标、枚举管方向、随机数生成食物、循环驱动游戏主流程。
这篇文章我会把整个项目的思路拆开揉碎了讲,包括蛇的数据结构怎么设计、移动算法到底在做什么、碰撞检测有哪些容易踩的坑,最后给出一份可以直接编译运行的完整C语言源码。无论你是刚学完指针和结构体的新手,还是想找个项目巩固基础的在校生,这份内容都能让你在两小时内从零跑通一个属于自己的贪吃蛇游戏。
1. 贪吃蛇项目的整体设计与核心思路
1.1 先想清楚一个问题:这个游戏到底在做什么
很多新手写贪吃蛇,拿起来就开始敲代码,结果写着写着就乱了。其实写任何代码之前,都应该先回答一个问题:这个程序的本质是什么?
贪吃蛇拆开来看,只有三个对象:蛇、食物、地图。游戏规则是蛇不断移动,吃到食物变长,撞墙或者咬到自己就结束。
听起来很简单,但这里藏着一个关键难点:蛇是一个动态变化的对象,它的状态每时每刻都在变。位置在变、长度在变、方向在变。怎么用一个C语言的“容器”去描述一条会动的蛇?这是我当年第一个卡住的地方。
答案是:用数组存蛇的每一节坐标,用变量记录蛇的当前长度和方向。蛇的动态变化,本质上就是数组内容的不断更新。想通了这一点,整个游戏的核心逻辑就已经通了七成。
1.2 控制台游戏的基本运行模式
贪吃蛇不是图形界面游戏,它跑在控制台窗口里。控制台游戏有一个固定的套路:循环轮询输入、更新游戏状态、重绘画面、控制速度。这个循环就是所谓的主循环,游戏世界里的所有变化,都发生在这个循环的一轮一轮迭代中。
主循环的四个步骤缺一不可:
- 第一步,检测输入:玩家有没有按方向键?按了什么键?
- 第二步,更新状态:根据输入和当前方向,算出蛇头下一步要去哪里,检查是吃到食物还是撞墙。
- 第三步,重绘画面:把最新的蛇和食物位置画到屏幕上。
- 第四步,控制节奏:Sleep函数让程序休息一会儿,不然蛇会快得根本看不清。
我经常拿这个循环跟拍电影类比:每一帧画面拍下来然后连续播放,就成了动态效果。控制台游戏的每一轮循环,就是所谓的“帧”,循环跑得越快,蛇动得越快。
1.3 为什么说这是最适合C语言初学者的项目
链表可以写贪吃蛇,图形库可以写贪吃蛇,但用纯C语言加控制台来写,学习成本最低,知识密度最高。这个项目覆盖的知识点几乎是C语言入门的全部重点:
- 数组:用数组存蛇身的每一节,这是数组最典型的使用场景
- 结构体:坐标用结构体封装,比拆成两个零散的x和y直观得多
- 枚举:用枚举表示上下左右四个方向,代码可读性大大提升
- 函数:初始化、绘制、移动、碰撞检测,每个功能拆成一个函数,体验模块化编程
- 随机数:食物位置用rand生成,避免每次打开游戏食物都在同一个地方
一个项目把C语言的半壁江山都练到了,这是贪吃蛇最大的吸引力。
2. 数据结构与模块设计:动手写代码前的关键决策
2.1 蛇身到底用数组还是链表
这个问题我建议初学者直接用数组。原因很简单:在20x20的地图上,蛇身最长不会超过400节,数组完全装得下,而数组的“随机访问”特性让蛇身的移动处理变得非常简单。
用链表当然也可以,每一节是一个节点,吃到食物就动态分配一个节点插入尾部。链表的优点是内存按需分配,理论上蛇可以无限长,但代价是代码复杂度明显上升,插入删除节点还要小心内存泄漏。对于练手项目,数组是更合理的选择。
数组方案的核心定义就这几行:
// 坐标结构体:x代表列,y代表行 typedef struct { int x; int y; } Point; // 蛇身数组:地图大小WIDTH*HEIGHT,蛇身最长就是全图铺满 Point snake[WIDTH * HEIGHT]; int snakeLen; // 当前蛇身长度 int dir; // 当前移动方向有一点需要注意,snake[0]是蛇头,snake[snakeLen-1]是蛇尾。后面写移动算法的时候,这个顺序关系必须记住。
2.2 地图、食物与游戏状态的定义
地图我直接用WIDTH和HEIGHT两个宏定义,一个是地图宽,一个是地图高。之所以用宏而不是普通变量,是因为编译时数组的大小需要常量,而且以后想改成15x15的地图,只改这一处就够了。
#define WIDTH 20 #define HEIGHT 20地图边界用什么表示?很多教学代码把地图边界直接“画”成一个二维数组,数组里用0表示空地,1表示墙壁。这样当然可以,但代码会更绕。我这里用更直接的方式:绘制画面的时候,在四周画一圈#字符当作墙,玩法的碰撞检测单独做,判断蛇头坐标是否越界即可。
食物用一个Point结构体表示,不需要数组,因为同一时刻地图上只有一个食物。得分用一个整型变量,初始0,每吃一个食物加10分。
游戏状态用一个布尔变量gameOver表示,0表示游戏正在运行,1表示游戏结束。这个变量的存在,是主循环能正常退出的关键。
2.3 按功能拆成四个模块
写代码最怕的是把所有逻辑堆在main函数里,写到后面自己都理不清。贪吃蛇再小,也值得按功能拆分。
我建议至少拆成四个模块:
| 模块 | 函数 | 职责 |
|---|---|---|
| 初始化 | initGame() | 设置蛇的初始位置、长度、方向、分数,生成第一个食物 |
| 绘制 | drawGame() | 清屏、绘制边界/蛇/食物、输出得分和操作提示 |
| 逻辑更新 | moveSnake() | 计算新蛇头位置、碰撞检测、蛇身平移、吃食物 |
| 工具函数 | isOnSnake()、generateFood()、keyPressed() | 判断坐标是否在蛇身上、生成食物、检测按键 |
模块化的好处是显而易见的:每个函数的职责单一,出bug的时候能很快定位到具体函数,而且主循环看起来非常清爽。
3. 核心算法拆解:移动、吃食与碰撞检测的细节
3.1 蛇的移动本质是“整体平移”
理解蛇的移动算法,是整个项目最关键的一步。
很多人的第一反应是:让蛇头的坐标随着方向变化,然后蛇身跟着头走。这个思路没错,但“跟着走”具体怎么实现,就有讲究了。
我采用的算法非常简单:从蛇尾开始,每一节复制前一节的位置,最后蛇头移动到新位置。
举个例子,假设蛇现在是这样的,蛇头在(4,5),身体依次在(3,5)、(2,5),方向向右。这一帧要向右移动一格,算法执行后:
- 先把最后一节(2,5)的位置,更新为倒数第二节原来在的位置(3,5)
- 再把倒数第二节(3,5)的位置,更新为原来第一节的位置(4,5)
- 最后把第一节,也就是蛇头,更新为新坐标(5,5)
用代码写出来就是:
// 从尾到头逐个覆盖位置 for (int i = snakeLen - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0] = next; // next是计算的蛇头新位置这个过程特别像排队往前走:所有人集体向前迈一步,队伍后面的空位自然就被填上了。关键是循环必须从尾部开始、往头部方向走,如果你从头部开始覆盖,数据会被提前篡改,蛇身就全乱了。
3.2 碰撞检测的两处关键坑
碰撞检测是贪吃蛇出bug最多的环节,主要有两种碰撞:撞墙和咬到自己。
撞墙的判断非常直接。蛇头坐标超出了地图的0到WIDTH-1、0到HEIGHT-1范围,就说明撞墙了,gameOver设为1,游戏结束。
if (next.x < 0 || next.x >= WIDTH || next.y < 0 || next.y >= HEIGHT) { gameOver = 1; return; }咬到自己的判断稍微复杂一点,这里有个容易被忽略的细节:蛇尾那一节要不要参与碰撞判定?
答案是:吃到食物时蛇尾不移动,蛇尾参与碰撞判定;没吃到食物时蛇尾会前移,蛇尾不参与碰撞判定。
我用一个limit变量来处理这个差异,逻辑非常清晰:
int eatFood = (next.x == food.x && next.y == food.y); // 如果没有吃到食物,蛇尾会前移,所以最后一个节点不必参与判断 int limit = eatFood ? snakeLen : snakeLen - 1; for (int i = 0; i < limit; i++) { if (snake[i].x == next.x && snake[i].y == next.y) { gameOver = 1; return; } }这个细节不处理好,会出现一种很诡异的现象:蛇明明没吃到食物,却因为要移动到蛇尾原来所在的位置而被判游戏结束,玩起来特别冤。
3.3 键盘控制:用GetAsyncKeyState实现非阻塞输入
键盘输入是控制台游戏和普通命令行程序区别最大的地方。如果你用传统的getch()读键盘,程序会卡在那里等用户按键,蛇就不会自动走了。游戏要的是“蛇持续移动,按键随时响应”,所以必须用非阻塞的按键检测方式。
Windows下最方便的方案是GetAsyncKeyState函数。它会返回某个键当前是否处于按下状态,不需要等待按键,主循环每一轮都能检查一次。
// 检测某个键是否被按下 int keyPressed(int vk) { return (GetAsyncKeyState(vk) & 0x8000) != 0; }方向控制用四个方向键加WASD两组方案,总有一种适合玩家的操作习惯:
if (keyPressed(VK_UP) || keyPressed('W')) { if (dir != DIR_DOWN) dir = DIR_UP; }注意这里有个细节:不能直接反向。比如蛇正在向右移动,玩家按了左方向键,如果此时我把方向改为左,那么蛇头在下一帧就会直接穿进自己的身体,这在游戏里是违法的。所以每个方向都要检查是不是和当前方向相反,相反就忽略这个输入。
3.4 主循环的节奏控制
主循环的最后一件事是控制速度。用Sleep(150)让程序每轮循环暂停150毫秒,蛇大约每秒移动6格多,手感比较适中。想让蛇更快,把数值调小;想慢一点,调大。
这里补充一个使用GetAsyncKeyState的坑:同一轮循环里不能连续用多个独立的if去判断方向键,否则可能出现一帧之内蛇拐了两次方向这种bug。正确做法是使用else if连起来,每轮最多响应一次方向变化:
if (keyPressed(VK_UP) || keyPressed('W')) { if (dir != DIR_DOWN) dir = DIR_UP; } else if (keyPressed(VK_DOWN) || keyPressed('S')) { if (dir != DIR_UP) dir = DIR_DOWN; } else if (keyPressed(VK_LEFT) || keyPressed('A')) { if (dir != DIR_RIGHT) dir = DIR_LEFT; } else if (keyPressed(VK_RIGHT) || keyPressed('D')) { if (dir != DIR_LEFT) dir = DIR_RIGHT; }4. 完整源码与运行步骤
4.1 完整源码分享
下面这份代码我已在MinGW GCC和Visual Studio的Windows控制台环境下编译测试过,直接复制保存为snake.c即可运行。
#include <stdio.h> #include <stdlib.h> #include <windows.h> #include <time.h> #define WIDTH 20 #define HEIGHT 20 // 方向枚举 enum Direction { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT }; // 坐标结构体 typedef struct { int x; int y; } Point; // 全局变量:蛇、食物、游戏状态 Point snake[WIDTH * HEIGHT]; int snakeLen; int dir; Point food; int score; int gameOver; // 函数声明 void generateFood(); int isOnSnake(int x, int y); // 判断某个坐标是否被蛇占据 int isOnSnake(int x, int y) { for (int i = 0; i < snakeLen; i++) { if (snake[i].x == x && snake[i].y == y) { return 1; } } return 0; } // 生成食物:随机选一个空地 void generateFood() { do { food.x = rand() % WIDTH; food.y = rand() % HEIGHT; } while (isOnSnake(food.x, food.y)); } // 初始化游戏 void initGame() { snakeLen = 3; dir = DIR_RIGHT; score = 0; gameOver = 0; // 蛇头在地图中央,身体向左依次展开 snake[0].x = WIDTH / 2; snake[0].y = HEIGHT / 2; snake[1].x = snake[0].x - 1; snake[1].y = snake[0].y; snake[2].x = snake[0].x - 2; snake[2].y = snake[0].y; srand((unsigned)time(NULL)); generateFood(); } // 绘制游戏界面 void drawGame() { system("cls"); // 上边界 for (int i = 0; i < WIDTH + 2; i++) { printf("#"); } printf("\n"); // 游戏区域 for (int y = 0; y < HEIGHT; y++) { printf("#"); for (int x = 0; x < WIDTH; x++) { if (snake[0].x == x && snake[0].y == y) { printf("@"); // 蛇头 } else if (isOnSnake(x, y)) { printf("O"); // 蛇身 } else if (food.x == x && food.y == y) { printf("$"); // 食物 } else { printf(" "); } } printf("#\n"); } // 下边界 for (int i = 0; i < WIDTH + 2; i++) { printf("#"); } printf("\n"); printf("得分: %d\n", score); printf("W/上=上移 S/下=下移 A/左=左移 D/右=右移 空格=暂停 Q=退出\n"); } // 移动蛇身 void moveSnake() { Point next; next.x = snake[0].x; next.y = snake[0].y; switch (dir) { case DIR_UP: next.y--; break; case DIR_DOWN: next.y++; break; case DIR_LEFT: next.x--; break; case DIR_RIGHT: next.x++; break; } // 撞墙检测 if (next.x < 0 || next.x >= WIDTH || next.y < 0 || next.y >= HEIGHT) { gameOver = 1; return; } // 咬到自己检测 int eatFood = (next.x == food.x && next.y == food.y); int limit = eatFood ? snakeLen : snakeLen - 1; for (int i = 0; i < limit; i++) { if (snake[i].x == next.x && snake[i].y == next.y) { gameOver = 1; return; } } // 蛇身整体平移 for (int i = snakeLen - 1; i > 0; i--) { snake[i] = snake[i - 1]; } snake[0] = next; // 吃到食物 if (eatFood) { snakeLen++; score += 10; generateFood(); } } // 封装按键检测 int keyPressed(int vk) { return (GetAsyncKeyState(vk) & 0x8000) != 0; } int main() { int paused = 0; int lastSpaceState = 0; initGame(); while (!gameOver) { // 方向控制:禁止反向行驶 if (keyPressed(VK_UP) || keyPressed('W')) { if (dir != DIR_DOWN) dir = DIR_UP; } else if (keyPressed(VK_DOWN) || keyPressed('S')) { if (dir != DIR_UP) dir = DIR_DOWN; } else if (keyPressed(VK_LEFT) || keyPressed('A')) { if (dir != DIR_RIGHT) dir = DIR_LEFT; } else if (keyPressed(VK_RIGHT) || keyPressed('D')) { if (dir != DIR_LEFT) dir = DIR_RIGHT; } // 空格按下沿触发暂停切换 int spaceState = keyPressed(VK_SPACE); if (spaceState && !lastSpaceState) { paused = !paused; } lastSpaceState = spaceState; if (paused) { Sleep(50); continue; } // 按Q退出 if (keyPressed('Q')) { break; } moveSnake(); drawGame(); Sleep(150); } if (gameOver) { printf("游戏结束!最终得分: %d\n", score); } system("pause"); return 0; }4.2 编译运行方法
Windows下一般有两种编译方式,任选其一:
第一种,用Visual Studio:新建一个“空项目”或“控制台应用”,把上面的代码粘贴到主源文件里,直接按Ctrl+F5编译运行。如果你用的是旧版VS,可能需要把项目字符集改成“多字节字符集”,否则个别环境会有编译告警。
第二种,用MinGW GCC:把代码保存成snake.c,打开命令提示符进入文件所在目录,执行:
gcc snake.c -o snake.exe snake.exe如果提示windows.h找不到,说明你装的GCC不是MinGW版本,换成安装了MinGW的编译器再试。另外,这个游戏跑在Windows控制台里,Linux和macOS直接编译会有问题,因为windows.h和GetAsyncKeyState是Windows平台特有的接口。
4.3 关键代码的运行逻辑串联
我建议你重点阅读三个函数的执行顺序:initGame只在游戏开始时调用一次,完成所有初始化;moveSnake和drawGame在while循环里被反复调用,一个负责“更新世界”,一个负责“画出世界”。
还有一个容易忽略的点:moveSnake里计算蛇头新位置时,我使用了snake[0]的坐标。因此在调用moveSnake之前,绝对不能让snake[0]被提前修改。把移动和绘制分开成两个函数,能从结构上避免这类错误。当初我偷懒把移动逻辑塞进绘制函数里,结果调试到半夜才发现蛇头永远慢了一拍。
5. 常见问题与调试实录
5.1 按方向键蛇没反应
这是最常见的现象。多半是输入处理用了阻塞函数,比如getch()或者scanf(),程序卡在等用户输入那里,蛇根本不会自动移动。换用GetAsyncKeyState就好了。注意这个函数是Windows API,使用前必须#include <windows.h>。
5.2 蛇能180度掉头然后瞬间死亡
很多第一次写贪吃蛇的朋友都会遇到。蛇正在向右走,你按下左键,下一帧蛇头直接撞到自己身上,游戏结束。解决方式就是我在前面代码里写的,接收方向输入时,先把新方向和当前方向做对比,才允许转向:
if (keyPressed(VK_UP) || keyPressed('W')) { if (dir != DIR_DOWN) dir = DIR_UP; }5.3 按一下方向键蛇拐了两次弯
这个bug很有迷惑性。原因通常是键盘输入检测用了多个独立的if而不是else if。假设玩家按了上和左两个键(或者恰好在一帧内两个键都被检测到),独立的if会把方向连续改两次,蛇就像瞬移一样拐了两次。改成else if之后,一轮最多改一次方向,问题就消失了。
5.4 画面闪烁很严重
system("cls")是清屏重绘最粗暴的方式,每次清屏相当于把整个画面抹掉再重画,闪烁是必然的。这是控制台贪吃蛇的通病,不致命但确实影响体验。想改善有两个方向:一是把Sleep时间调大一点,降低刷新频率;二是用Windows API的光标定位函数,比如SetConsoleCursorPosition,每次只把光标移到左上角再重绘,而不是清屏,效果会好很多。
5.5 游戏速度想调整却找不到地方
速度完全由主循环末尾Sleep(150)来控制,数值越小越快。想做一个“每吃5个食物加速一次”的难度系统,只需要在moveSnake里让Sleep的参数根据得分动态变化,比如:
int delay = 150 - score / 50; if (delay < 50) delay = 50; Sleep(delay);5.6 Linux或者macOS的GCC编译报错
这个项目的核心依赖是windows.h和GetAsyncKeyState,两者都是Windows专属。跨平台有两条路:一条是用ncurses库,处理键盘输入和光标控制,但代码改动较大;另一条是干脆在Windows环境里跑。如果你是在Linux下学习C语言,可以把目标改成“在Linux控制台跑贪吃蛇”,搜索ncurses的教程,那套方案同样经典。
6. 把它做得更完整:三个实用扩展方向
6.1 增加难度等级与最高分
目前游戏难度是写死的,玩久了会腻。扩展的思路并不复杂:用变量level表示难度,每吃5个食物难度加1,对应的Sleep时间缩短;再用一个文件操作把历史最高分存到本地文件里,下次启动游戏读取并展示。这个扩展还顺手练习了C语言的文件读写,一举两得。
6.2 用链表替换数组存储蛇身
用数组存蛇身实现简单,但其实对C语言的动态内存管理技能没什么锻炼。把Point snake[WIDTH * HEIGHT]改成单向链表,蛇进步时在尾部删除节点,吃到食物时在头部插入节点,是理解指针和链表非常好的练习题。做这个扩展之前,建议先熟练掌握malloc和free,写完之后用valgrind之类的工具检查一下有没有内存泄漏。
6.3 图形界面版本
控制台版本再怎么说也只是字符画。如果学完C语言想看看自己写的逻辑真正跑在图形窗口里是什么效果,可以搭配SDL2或EasyX图形库。需要注意的是,核心逻辑代码——数组存储蛇身、移动平移算法、碰撞检测、吃食物判定——完全可以复用,你只需要把drawGame函数换成图形绘制,把键盘检测从GetAsyncKeyState换成图形库的事件类型。逻辑和界面分离的好处,在这种改造里体现得淋漓尽致。
最后再分享一个小技巧:想让蛇在控制台里显示得更清楚,可以把字符换成全角符号,比如用“■”表示蛇身、用“●”表示食物,记得把控制台字体调成中文字体。实测下来效果比单纯用字母好很多。这个项目虽然小,但它值得你认真打磨——很多人C语言能力的转折点,就是从这样一个能玩、能改、能优化的贪吃蛇开始的。