
C语言写贪吃蛇是我见过最适合新手练手的控制台小游戏没有之一。一个小小的程序代码里几乎囊括了C语言最常用的语法点数组或链表、指针、结构体、随机数、按键输入、循环控制、函数封装。你把这些东西拼起来运行就能看到一个蛇在屏幕上吃食物、变长、然后一头撞死在墙上。这种即时反馈带来的成就感比刷一百道语法题都管用。我入行那会儿就是用贪吃蛇入门C语言的后来带实习生、给学弟学妹讲基础课也总推荐他们从这个小项目做起。它不只是一个写着玩的东西。你在计算机二级题目里遇到过的链表操作、在课程作业里写过的文件读写、在嵌入式开发里反复用的状态循环这个游戏里全都能碰到。这篇文章我会把C语言实现贪吃蛇这件事从头拆到尾为什么要选链表来存蛇身、方向控制怎么处理、碰撞检测有哪些坑、代码怎么组织才不乱再附上一份可以完整运行的参考代码以及我在不同环境下编译运行踩过的坑。适合刚学完C语言基础语法但不知道怎么写完整程序的人也适合想复习指针、链表、控制台编程的读者。1. 项目整体设计与思路拆解1.1 为什么贪吃蛇适合作为C语言练手项目如果你去问教C语言的老师学完语法之后该写什么十个里有八个会推荐贪吃蛇。原因很简单这个游戏看起来简单但它的逻辑链是完整的不掺杂任何多余的东西。一个贪吃蛇程序需要解决几件事有一条能动的蛇、有随机生成的食物、蛇吃到食物会长长、蛇撞墙或者撞到自己就死、分数和速度要有反馈。这几件事往细里拆正好对应C语言里最核心的知识点。我见过不少初学者学完指针、结构体之后打开编辑器面对空白的main函数发呆不知道这些语法到底有什么用。贪吃蛇给出了一个明确的目标你得用一个数据结构来表示一条蛇。用数组可以用链表也可以但用链表你能真正理解动态分配内存和指针操作到底解决了什么问题。当你亲手把malloc、free、结构体指针串起来跑出一个能玩的游戏这些语法才真正变成你自己的东西。另外贪吃蛇的项目边界特别清晰。它不需要你掌握图形界面不需要复杂的文件系统一个控制台窗口就能跑起来。从小处说一个晚上能写出基本能玩的版本不会因为项目太大半途而废往大处说它可以无限扩展加速、关卡、最高分、图形界面、AI自动寻路每一步都是新的知识点。别小看这个可扩展性很多人学编程就是靠这种连续的小胜利撑下来的。1.2 技术路线选型控制台版还是图形版写贪吃蛇第一件要决定的事用纯控制台还是引入图形库。纯控制台的方案在Windows下会用到windows.h里的控制台API比如SetConsoleCursorPosition定位光标、Sleep控制速度加上conio.h里的_kbhit和_getch做非阻塞按键读取。严格来说这些不是标准C库但它们几乎是Windows控制台编程的标配。好处是依赖极少一个编辑器、一个编译器就能跑你只需要专注在游戏逻辑本身。图形库的方案常见的有EasyX、SDL2、raylib。好处是界面好看可以画图片、加音效、做动画但代价是引入了一堆环境配置和图形渲染的概念。我之前见过一个新手第一次写贪吃蛇就折腾SDL2结果环境配置花了两天游戏逻辑还没开始写就放弃了。我的建议很直接第一次做老老实实写控制台版。控制台版已经能把游戏逻辑讲清楚等你把逻辑吃透了再往图形界面迁移核心代码几乎可以复用只需要把绘制画面这部分替换成图形API。先跑起来再变好看这个顺序千万别反。1.3 功能模块划分与游戏主循环写小游戏最容易犯的错是一股脑把所有代码塞进main函数里。贪吃蛇虽然简单但麻雀虽小五脏俱全合理的模块划分能让代码清爽很多也方便之后扩展。我一般是这么拆的初始化模块、输入处理模块、移动逻辑模块、绘制模块、主循环。初始化负责创建蛇、生成第一个食物、设置初始分数和方向输入处理负责读取键盘并更新方向移动逻辑根据当前方向计算新蛇头位置判断是否吃到食物是否撞墙或撞到自己绘制模块负责把蛇、食物、分数画到控制台上主循环负责把前面几件事串起来循环执行。这个游戏本质上就是一个无限循环while (1) { 处理输入; 更新逻辑; 绘制画面; 延时; }延时这一步很关键。如果不延时循环会以CPU极限速度运行画面疯狂刷新蛇就在屏幕上瞬移延时太长操作又跟不上。Windows下一般用Sleep(毫秒)来控制帧率初始值我通常设在100到200毫秒之间后面可以根据分数逐步加快。游戏结束后退出循环释放内存收尾。2. 核心数据结构与关键实现原理2.1 蛇的表示数组方案对比链表方案蛇的数据结构是整个程序的地基选错了后面全是麻烦。常见方案有两种数组和链表。数组方案有两种做法。一种是开一个二维数组当作游戏棋盘蛇占据的位置标记为1每次移动更新数组另一种是用一维数组存蛇的每一节坐标比如一个个{x, y}结构体移动的时候把后面每节往前移动一位相当于整条蛇跟着走。数组方案的优点是直观随机访问快但缺点也明显蛇的长度不固定数组得开成最大值浪费空间移动这个操作要整体搬移数据蛇越长开销越大。链表方案是用一个单链表来存蛇身每个节点存一节身体的坐标。移动的时候在头部插入新蛇头如果没吃到食物就删掉尾节点这样蛇的长度不变吃到食物就保留尾节点蛇长加一。这个操作的时间复杂度是O(1)而且链表天然模拟了蛇头进尾出的运动方式非常形象。我个人强烈推荐用链表。理由有几点第一你能真正练到指针和动态内存管理这比任何语法练习题都实在第二链表操作和游戏逻辑是天然契合的理解起来反而不难第三面试或者笔试里考链表增删节点你早就在游戏里写过了。当然用数组也能实现只是那种移动一整个数组的笨重感写一次你就会想换方案了。2.2 食物生成与碰撞检测食物生成看起来简单其实有个细节容易出问题生成的坐标不能落在蛇身上。生成逻辑分两步先随机生成一个在游戏区域内的坐标再遍历蛇身检查是否有重合如果有就重新生成直到合法为止。这里有个C语言的经典坑rand()生成的伪随机序列是固定的如果你不在程序开始时调用srand((unsigned)time(NULL))设置随机种子那每次运行游戏第一个食物出现的位置都是一样的玩起来非常诡异。我见过好几个新手在这里卡住还以为是自己代码写错了其实是忘了初始化随机种子。碰撞检测分两部分撞墙和撞自己。撞墙比较简单看新蛇头的坐标是否超出游戏区域的边界范围。撞自己的逻辑有点讲究移动之后新蛇头坐标如果和蛇身某一节重合就game over。但是要注意如果蛇尾在移动之后要被移除新蛇头撞到旧蛇尾其实是刚好擦过去不算撞到。所以正确的顺序应该是先判断是否吃到食物如果没吃到就先删掉尾节点再检查蛇头是否和剩下的身体重合。顺序搞反的话蛇经常会无缘无故判死体验很差。2.3 方向控制与游戏结束判定方向控制是贪吃蛇的交互核心也是新手经常写崩的地方。首先要定义一个方向枚举用UP、DOWN、LEFT、RIGHT而不是裸的数字可读性会好很多。然后在输入处理里更新方向但有个关键约束蛇不能反向移动。你正在向右走的时候按了左键蛇头直接撞到自己的第二节身体一秒内game over这种体验非常崩溃。所以每次更新方向前都要判断一下如果要设置的方向和当前方向相反就忽略这次输入。另一个坑是按键读取。如果用标准库的getchar()或者scanf()需要按回车才会返回根本无法做到实时操控。Windows下要用_kbhit()先检测有没有键被按下再用_getch()读取按键这样蛇才能在你按下的瞬间做出反应。方向键的处理还有一层坑方向键在Windows控制台里读出来是两个字节第一次调用_getch()返回一个标志值通常是224或0第二次调用才返回具体的扫描码72是上、80是下、75是左、77是右。如果只读一次拿到的是那个224方向判断全部失效。所以代码里要先判断是不是扩展键再二次读取。我习惯同时支持方向键和WASD这样键盘操作更顺手。游戏结束判定就是一个标志位的事。置为1主循环退出然后打印最终分数、释放内存。不要在主循环里写一堆复杂的退出条件用一个gameOver全局变量逻辑清晰得多。3. 从零到一手写一个可运行的C语言贪吃蛇3.1 开发环境准备不同平台、不同编译器运行这段代码的体验差别挺大先说一下环境。Windows下最省事的方式是装一个MinGW-w64配好环境变量后在命令行用gcc编译。也可以直接用Visual Studio新建空项目把.c文件加进去编译运行。如果你习惯VSCode装好C/C扩展设置好编译器路径写代码和调试都很方便。我推荐新手用一个轻量编辑器加命令行gcc的方式因为这样你能看到完整的编译报错过程对理解编译链接有帮助。Linux/macOS下没有conio.h和windows.h控制台编程要换一套方案一般是配合ncurses库来实现实时按键和光标定位。我这里给出的参考代码是基于Windows控制台的在Linux跑会直接编译失败。如果跨平台可以后面再迁移。还有一点控制台窗口大小会影响显示效果。如果窗口太小棋盘显示不全或者蛇跑着跑着画面就乱了。我在Windows下一般先把控制台窗口尽量拉大或者把代码里的WIDTH和HEIGHT调小一点保证棋盘能完整显示。3.2 辅助函数光标定位、按键读取、画面刷新控制台版贪吃蛇有三种常见的画面刷新方式。第一种是system(cls)清屏再重绘代码最简单但清屏会有明显的闪烁玩起来眼睛难受。第二种是每次移动只更新变化的位置性能好但代码复杂。第三种是我推荐的方式用一个gotoxy()函数把光标定位到左上角然后整帧重绘这样既不会有cls那种剧烈闪烁代码也足够简单。gotoxy()在Windows下要用SetConsoleCursorPosition它的参数是一个COORD结构体里面是X和Y坐标。代码我会在完整代码里给出。还有一个我习惯做的事是隐藏控制台光标不然光标一闪一闪的看着很干扰游戏画面。按键读取分成两步先_kbhit()检查缓冲区有没有按键有的话_getch()读取。这里我再强调一次方向键要读两次第一次判断是不是扩展键第二次读取实际方向码。3.3 核心逻辑移动、吃食物、死亡判断移动逻辑是贪吃蛇的心脏写明白这一段整个游戏就通了。我拆开来讲第一步根据当前方向算出新蛇头的坐标。向右走就是X加一向左走X减一向下Y加一向上Y减一。第二步判断新蛇头是否撞墙。如果X或Y超出了游戏区域的边界gameOver置1直接返回。第三步判断新蛇头是否吃到食物。判断方法是新蛇头的坐标和食物坐标是否相等。第四步创建新节点把它插到链表头部成为新的蛇头。如果吃到了食物保留尾部蛇长加一分数增加重新生成食物如果没吃到删除链表尾节点蛇长保持不变。第五步检查新蛇头是否撞到自己。遍历链表看有没有和蛇头坐标重合的节点。注意这一步要在删除尾节点之后做逻辑才是正确的。还有一点很多人会忽略链表里的节点都是malloc出来的程序退出之前要遍历链表把内存全部释放掉。虽然操作系统在你程序结束后也会回收但养成free的习惯很重要做嵌入式或者长期运行的程序时内存泄漏是要出大事的。3.4 完整参考代码下面这份代码是我在Windows控制台下跑通的版本GCC和Visual Studio都能编译。它包含了隐藏光标、实时键盘检测、链表蛇身、随机食物、记分加速、完整的内存释放。我尽量写得通俗注释也比较多方便你对照着理解。#include stdio.h #include stdlib.h #include time.h #include conio.h #include windows.h #define WIDTH 40 // 游戏区域宽度 #define HEIGHT 20 // 游戏区域高度 // 蛇身节点保存坐标和指向下一节的指针 typedef struct SnakeNode { int x; int y; struct SnakeNode* next; } SnakeNode; // 方向枚举 typedef enum { UP, DOWN, LEFT, RIGHT } Direction; SnakeNode* head NULL; // 蛇头 int foodX 0, foodY 0; // 食物坐标 int score 0; // 分数 int gameOver 0; // 游戏结束标记 Direction dir RIGHT; // 当前方向 int sleepTime 150; // 初始延时单位毫秒 // 光标定位到 (x, y) void gotoxy(int x, int y) { COORD coord; coord.X x; coord.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); } // 隐藏控制台光标 void hideCursor() { HANDLE h GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO info; info.dwSize 100; info.bVisible FALSE; SetConsoleCursorInfo(h, info); } // 随机生成食物确保不落在蛇身上 void generateFood() { int isValid 0; while (!isValid) { foodX rand() % WIDTH; foodY rand() % HEIGHT; isValid 1; for (SnakeNode* cur head; cur ! NULL; cur cur-next) { if (cur-x foodX cur-y foodY) { isValid 0; break; } } } } // 初始化游戏创建初始蛇、生成食物、隐藏光标 void initGame() { // 初始蛇长度为3从中间位置向左展开 head (SnakeNode*)malloc(sizeof(SnakeNode)); head-x WIDTH / 2; head-y HEIGHT / 2; head-next NULL; SnakeNode* tmp head; for (int i 1; i 3; i) { SnakeNode* node (SnakeNode*)malloc(sizeof(SnakeNode)); node-x WIDTH / 2 - i; node-y HEIGHT / 2; node-next NULL; tmp-next node; tmp node; } dir RIGHT; score 0; gameOver 0; sleepTime 150; srand((unsigned)time(NULL)); generateFood(); hideCursor(); system(cls); } // 绘制整个画面 void draw() { gotoxy(0, 0); // 上边框 for (int i 0; i WIDTH 2; i) printf(#); printf(\n); // 游戏区 for (int row 0; row HEIGHT; row) { printf(#); for (int col 0; col WIDTH; col) { int isBody 0; for (SnakeNode* cur head; cur ! NULL; cur cur-next) { if (cur-x col cur-y row) { isBody 1; break; } } if (foodX col foodY row) printf(); else if (isBody) printf(*); else printf( ); } printf(#\n); } // 下边框 for (int i 0; i WIDTH 2; i) printf(#); printf(\n); // 分数和速度信息显示在棋盘右侧 gotoxy(WIDTH 4, 0); printf(Score: %d\n, score); printf(Speed: %d\n, sleepTime); } // 读取键盘输入更新方向 void input() { if (_kbhit()) { int key _getch(); // 方向键是扩展键第一次返回224或0 if (key 224 || key 0) { key _getch(); switch (key) { case 72: if (dir ! DOWN) dir UP; break; case 80: if (dir ! UP) dir DOWN; break; case 75: if (dir ! RIGHT) dir LEFT; break; case 77: if (dir ! LEFT) dir RIGHT; break; } } else { // 兼容 WASD 操作 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; } } } } // 蛇移动的核心逻辑 void move() { int newX head-x; int newY head-y; switch (dir) { case UP: newY--; break; case DOWN: newY; break; case LEFT: newX--; break; case RIGHT: newX; break; } // 撞墙检测 if (newX 0 || newX WIDTH || newY 0 || newY HEIGHT) { gameOver 1; return; } int eatFood (newX foodX newY foodY); // 新蛇头插入链表头部 SnakeNode* newNode (SnakeNode*)malloc(sizeof(SnakeNode)); newNode-x newX; newNode-y newY; newNode-next head; head newNode; if (eatFood) { score 10; // 每吃一个食物速度变快一点但设一个下限 if (sleepTime 50) sleepTime - 5; generateFood(); } else { // 没吃到食物删除尾节点 if (head-next NULL) { gameOver 1; return; } SnakeNode* prev head; while (prev-next-next ! NULL) { prev prev-next; } free(prev-next); prev-next NULL; } // 撞自己检测此时尾节点已经被删掉 for (SnakeNode* cur head; cur ! NULL; cur cur-next) { for (SnakeNode* other cur-next; other ! NULL; other other-next) { if (cur-x other-x cur-y other-y) { gameOver 1; return; } } } } // 释放链表内存 void freeSnake() { SnakeNode* cur head; while (cur ! NULL) { SnakeNode* next cur-next; free(cur); cur next; } } int main() { initGame(); while (!gameOver) { draw(); input(); move(); Sleep(sleepTime); } gotoxy(0, HEIGHT 3); printf(Game Over! Final Score: %d\n, score); printf(Press any key to exit...\n); _getch(); freeSnake(); return 0; }这段代码在Windows下直接用gcc编译就行gcc snake.c -o snake.exe跑起来之后用方向键或WASD控制蛇的方向吃到会加分并且长长每吃一个会稍微加速。撞墙或者咬到自己就结束最终分数会显示在棋盘下方。4. 编译运行与常见问题排查实录4.1 不同平台的编译运行方式我在Windows下最常用的是MinGW-w64编译命令很简单gcc snake.c -o snake.exe snake.exe如果你用Visual Studio新建一个空项目把文件加进去CtrlF5编译运行。需要注意的是Visual Studio默认可能用C方式编译但这份代码兼容C和C基本不会出问题。在Dev-C这种比较老的IDE里也能跑它自带的MinGW对conio.h和windows.h支持得很完整直接编译就行。Linux和macOS上这份代码编译不过因为conio.h和windows.h是Windows专属的。跨平台方案可以改成ncurses库Linux下先安装ncurses开发包然后代码里用getch()、mvprintw()这些API来实现实时按键和光标控制。逻辑部分可以完全复用只是把I/O层换掉。整体思路是一样的。4.2 常见问题速查表我在实际带人写这个项目时见过的问题翻来覆去就那么几个列个表给你遇到直接对着查。现象原因解决办法编译报错_kbhit未声明没包含或编译器不支持conio.h加上#include conio.h确认用的是Windows编译器编译报错Sleep未定义没包含windows.h加上#include windows.hLinux下改用usleep方向键按了没反应没处理扩展键只读了一次判断第一个字节为224或0后再读一次取方向码按左键蛇直接反向死亡没做反向保护在方向更新前判断当前方向的相反方向画面疯狂闪烁频繁使用system(cls)清屏改用gotoxy(0,0)后整帧重绘游戏界面中文乱码控制台代码页和源码编码不匹配代码里尽量不用中文或执行chcp 65001第一个食物位置每次一样没设置随机种子main里调用srand((unsigned)time(NULL))食物生成在蛇身上生成时没检查蛇身坐标生成后遍历链表重合则重新生成蛇有时候会凭空死亡撞自己检测在删除尾节点之前先删尾节点再检查撞自己程序运行后CPU占用高Sleep值太小或没Sleep设置合适的延时比如100到200毫秒这张表基本覆盖了控制台版贪吃蛇90%的报错和异常表现。你如果遇到了表里没有的情况大概率是链表操作越界或者空指针问题建议在可能出问题的地方加printf调试输出一步一步看蛇节点的坐标变化。4.3 我踩过的几个坑第一个坑是初学时用getchar()读按键结果蛇根本不走按一个键走一步还得回车确认。我当时还以为是自己逻辑写错了后来才知道控制台程序要实时输入必须用_kbhit加_getch的非阻塞方案。这个认知转变对我理解输入缓冲区帮助挺大的。第二个坑是删尾节点的顺序搞反了。最早我是先遍历链表找尾节点删除再插入新蛇头结果蛇的移动轨迹完全不对蛇身会拖出一个断层。后来把顺序改成先插头、再删尾整个逻辑就顺了。这个顺序决定逻辑正确性的教训写复杂程序时很重要。第三个坑是内存泄漏。早期写完游戏不释放链表节点程序短时间运行也没啥感觉但后来我把这套逻辑搬到其他任务里长时间跑了几万次循环内存一直涨才意识到不free的后果。现在写C代码每次malloc我都习惯性想着这内存最后谁来释放这个习惯就是从贪吃蛇养成的。5. 进阶方向从能跑到写好5.1 游戏体验优化加速、计分、暂停与最高分基本版贪吃蛇跑通之后你会觉得太单调这时候就可以开始加功能了。我按性价比从高到低给你排一下。加速是最简单的优化在move()里吃到食物后把sleepTime减掉一点比如每次减5毫秒减到50毫秒为止。速度变化带来的紧张感会立刻让游戏有意思很多代码改动只要一两行。计分可以做得更丰富一点现在的版本是每个食物固定加10分你可以改成不同食物对应不同分数甚至让食物在一定时间后消失、刷新位置。这需要给食物结构体加一个过期时间字段每帧更新时判断逻辑不难但很有挑战。暂停功能也很实用。在input()里检测到P键就进入一个阻塞循环直到再按一次P才退出。注意暂停的时候要重新调用_getch()读取否则按键会残留到游戏循环里造成误操作。我写的第一版就吃过这个亏按P暂停完游戏立刻又走了一步。最高分持久化是练习文件读写的好场景。游戏结束时把分数写进一个文件下次启动时读取出来显示在界面上。核心代码就几行// 读取最高分 FILE* fp fopen(record.txt, r); int best 0; if (fp) { fscanf(fp, %d, best); fclose(fp); } // 游戏结束时更新 fp fopen(record.txt, w); if (fp) { fprintf(fp, %d, score); fclose(fp); }别小看这几行文件读写操作在C语言里很多新手只会做练习题真正放到一个有意义的项目里用一次比做十道题都印象深刻。5.2 界面升级从控制台到图形化控制台版玩熟了大家普遍会想这界面能不能好看点这时候就值得聊聊图形库了。Windows下最友好的入门图形库是EasyX它是给C/C用的一个简单图形库画矩形、写文字、处理鼠标事件都很直观。迁移思路非常简单把draw()函数里的printf坐标绘制换成solidrectangle画方块把_kbhit换成消息循环里的按键事件游戏逻辑几乎原封不动就能跑起来效果直接从黑白字符变成彩色方块。如果想要跨平台或者做更炫的效果SDL2也是个好选择。它比EasyX稍微复杂一点但跨平台能力强手机、游戏机、嵌入式上都能见到。到这一步你会发现游戏逻辑和渲染层分离的理念特别重要逻辑层完全不用变换一个渲染层就是另一个版本的贪吃蛇。我个人不太建议一上来就跳图形界面。先吃透控制台版的逻辑再去学渲染你已经知道要画什么只是不知道怎么画学习目标非常明确。反过来如果你连逻辑都没理清就去学图形库等于同时学两样东西难度翻倍。5.3 这个项目还能带给你什么贪吃蛇看起来只是个玩具项目但它练的东西在真实开发里非常实用。链表增删节点就是最典型的一个很多C语言笔试、面试、上机题就是让你实现一个链表操作你写贪吃蛇的时候早就练熟了。内存管理也是malloc和free配对使用的习惯动态内存不泄漏的意识都是在这类小项目里养成的。往深了说贪吃蛇本身就是一个简单状态机游戏运行中、暂停、结束这几个状态之间怎么切换本质上和嵌入式设备里的人机交互逻辑是一样的。我在后来做单片机项目、写协议解析、做状态机调度的时候经常想起当年写贪吃蛇时的那个while循环——处理输入、更新状态、输出结果这个模式几乎是所有交互程序的原型。如果你有兴趣这个项目还可以继续变出很多花样自动寻路的AI贪吃蛇、双人对战、障碍物关卡、局域网联机版。每一条路都能带出新的技术栈。C语言这条路走稳了后面学C、学Java、学Python都只是在换语法思维框架你已经有了。我个人在实际操作中有一个很明显的感受能把这个项目从头到尾写明白的人再回去看C语言的语法看那些指针、结构体、内存管理的概念理解完全不一样了。就像你亲手修过一辆自行车再去看维修手册每个零件都有画面感不再是抽象的文字。如果你正在学C语言卡在某个阶段真不妨花一个晚上亲手把这个贪吃蛇写出来那种原来如此的爽快感值得你亲自体验一下。