资讯动态

C语言贪吃蛇项目1.20版:数据结构与算法实战解析

发布时间:2026/10/6 8:40:05 来源:尧图企业网站定制
被朋友问过太多次“练C语言该做个什么项目”我推荐名单里排第一的始终是贪吃蛇。没别的原因这个游戏把数据结构、算法、输入处理、状态机这些核心概念全过了一遍却不需要牵扯图形库或者网络编程一个人一个终端就能搞定。这篇文章要聊的是我自己维护的贪吃蛇项目1.20版我不打算写一份“从零手把手教程”而是想以这个版本为样本拆一拆版本迭代背后的设计逻辑、关键算法怎么抠细节、以及我在这20轮改动里踩过的那些坑。有朋友可能会问“1.20”听起来不像一个正式版本号更像随便写的数字。这个判断对了一半。我确实没有按语义化版本那套主版本.次版本.修订号的规范来编号而是把它当成自己的开发里程牌每做一次功能升级或重要重构就把小版本加0.1攒到1.20这轮正好是足够稳定的状态。所以这篇文章讲的不只是“贪吃蛇怎么做”更是“一个贪吃蛇项目如何被反复打磨到值得分享的样子”。1. 从1.0到1.20经典项目的版本化演进1.1 贪吃蛇在C语言练手项目中的地位几乎所有学编程的人都会在某个阶段写一个贪吃蛇这已经快成一种传统了。道理其实很简单它麻雀虽小五脏俱全。一个完整的贪吃蛇至少涉及四块内容蛇身怎么存、怎么动食物怎么随机出现键盘输入怎么响应以及画面怎么刷新。这四块恰好对应数据结构、基础算法、外部事件处理和界面渲染覆盖面非常广。更难能可贵的是这四块内容的难度梯度控制得非常好。蛇身的存储用数组能做用链表也能做不同的数据结构会带来截然不同的代码风格碰撞检测要处理边界和自身重叠逻辑绕一下就能把新手绕晕输入响应看起来简单但控制台的按键读取机制里藏着扫描码、缓冲、异步输入这些容易踩坑的细节。更不用说在界面刷新时清屏时机和直接覆盖写字符的效果天差地别。换句话说写完一个能跑的贪吃蛇你至少能明确回答三个问题怎么组织一个连续变化的数据集合怎么处理“先判断后执行”的逻辑顺序以及怎么和用户产生良性互动。这三个能力恰好是后续做任何中大型项目的底层功底。正因如此我才会在已经写过不少项目之后仍然愿意花这么多轮迭代去维护一个贪吃蛇。1.2 1.20版到底做了什么说到1.20这版的具体功能我先列个清单。地图是30列20行的字符网格蛇身用字符块表示食物用另一个字符显示。支持四个方向移动支持WASD和方向键两套操作空格键暂停与继续。计分规则是每个食物10分同时提供三档难度选择对应不同的移动速度。游戏结束时显示本局得分和本地历史最高分按回车即可重开。单看这份清单功能不算花哨甚至有点朴素。但我并不觉得朴素是缺点反而是一种刻意的选择。贪吃蛇最大的价值在于逻辑清晰如果硬塞进去一堆道具系统、皮肤切换、关卡编辑器代码复杂度会迅速膨胀反而失去了“练手项目”的本来意义。1.20这版的核心目标是在保持代码可读性的前提下把体验补齐让玩家玩得舒服让读者看得明白。所以我特别做了一件事把“游戏进行中”和“操作响应”这两个维度彻底拆开。游戏循环每帧只做一次移动判断按键输入单独走一个读取流程两者之间用当前方向这个变量衔接。这样既避免了输入过快导致蛇身多走一步的问题也保证了暂停期间画面不会被误操作影响。这个设计看起来不起眼实际上是我在1.10时代反复调整后得出的结论。1.3 设计取舍为什么坚持纯控制台实现聊一下技术选型。现在随便搜个贪吃蛇教程很大概率会带一个图形界面库有的用Qt有的用EasyX有的直接用网页版。这些方案当然各有优势但我给1.20定的调性是零依赖纯C语言控制台里跑任何一台装了gcc或者Visual Studio的机器都能直接编译运行。这个取舍有几个实际考量。第一图形库的学习曲线会喧宾夺主使用者还没理解蛇身链表怎么设计先要被窗口初始化和事件循环折腾一通。第二控制台渲染反而能暴露更多底层问题比如光标定位、清屏闪烁、输入缓冲这些都是后期做复杂项目时会遇到的真实问题。第三也是我私心的一点终端字符界面做出来的贪吃蛇带一种复古味道那个字符方块在黑色背景里游走的感觉其实很带感。当然纯控制台也有短板最大的就是方向键的处理。控制台读取键盘不像图形界面那样给你一个标准事件接口必须自己解析按键序列这也是我在第4章要重点讲的内容。如果当初选图形库这些坑根本不存在但那份“亲手对抗系统细节”的体验也不会有了。2. 核心逻辑拆解数据结构与算法的关键决策2.1 蛇身的两种表示方案与最终选择贪吃蛇最核心的数据结构就是蛇身。主流方案有两个固定数组和动态链表。数组方案通常配合循环队列使用申请一个大数组用头部下标和尾部下标圈出一段连续元素每次移动时头部下标前进尾部下标跟着前进蛇身占用的就是数组里的一小段区域。链表方案则是一条真正的单链表每个节点存一个坐标移动时在头部插入新节点在尾部删除旧节点蛇的长度变化非常自然。两种方案各有优劣我对比过多次。数组方案的内存是连续的访问速度快代码里不容易出现空指针问题但蛇身长度有上限而且“哪一段是有效蛇身”需要靠下标维护思考负担并不小。链表方案直观灵活增删节点用malloc和free就能搞定但每一次移动都要遍历找尾节点的前驱而且在删除和释放时要格外小心指针状态。最终我在1.20里选了链表同时用二维数组做地图映射。为什么这么组合因为贪吃蛇这类游戏蛇身长度是动态变化的链表的灵活性最贴合这个场景而地图用二维数组可以在O(1)时间内判断某个格子是空地、食物还是蛇身这给碰撞检测和食物生成省了很大力气。两个结构各管一摊职责清晰代码读起来也顺畅。#define ROW 20 #define COL 30 enum { EMPTY 0, SNAKE, FOOD }; enum { UP 0, DOWN, LEFT, RIGHT }; typedef struct Node { int x; int y; struct Node *next; } Node; typedef struct { Node *head; Node *tail; int length; int dir; int score; int state; // 0运行 1暂停 2结束 } Snake; typedef struct { int grid[ROW][COL]; } Map;这套结构我已经用了很久新加功能基本不需要动骨架。比如加一个“蛇身最长记录”只需要在Snake里加一个字段初始化时赋值即可。2.2 移动算法头插尾删与碰撞判定的顺序蛇的移动可以抽象成两件事头部前进一格尾部根据是否吃到食物决定要不要收缩。很多第一次写贪吃蛇的朋友会把移动想得很复杂其实核心就是“头插尾删”四个字。没吃到食物时头部插入新坐标尾部删除旧坐标长度保持不变吃到食物时头部插入新坐标尾部不动长度加一。这里有个非常容易忽略的顺序问题先判断碰撞还是先移动我的做法是先根据当前方向算出新头部坐标然后立刻做边界碰撞判断再做移动最后做自撞判断。为什么自撞放在最后因为新头部进入的那个格子如果正好是尾部即将离开的格子严格来说不应该算碰撞毕竟这一格马上就要空出来了。如果先判断自撞再移动就会把“追尾自己尾巴”这种合法动作误判成游戏结束。int snake_move(Snake *s, Map *map) { int nx s-head-x; int ny s-head-y; switch (s-dir) { case UP: ny--; break; case DOWN: ny; break; case LEFT: nx--; break; case RIGHT:nx; break; } if (nx 0 || nx COL || ny 0 || ny ROW) { return -1; // 撞墙 } int hit_food (map-grid[ny][nx] FOOD); Node *new_head (Node*)malloc(sizeof(Node)); new_head-x nx; new_head-y ny; new_head-next s-head; s-head new_head; if (hit_food) { s-length; s-score 10; } else { Node *cur s-head; while (cur-next ! s-tail) { cur cur-next; } free(s-tail); s-tail cur; s-tail-next NULL; } Node *p s-head-next; while (p) { if (p-x nx p-y ny) { return -2; // 撞到自己 } p p-next; } return hit_food ? 1 : 0; }移动之后的自撞判定一定要遍历整条蛇不能只看头部附近的几个节点。蛇身长起来以后蛇头完全有可能从蛇身中间穿过那种“螺旋式自杀”的场景我见过太多次了。另一个细节是移动函数里返回值的含义要定义清楚1表示吃到食物0表示正常移动-1撞墙-2自撞这样游戏主循环可以根据返回值决定要不要刷新食物和结束游戏。2.3 食物生成与随机数陷阱食物生成看起来是最简单的功能一句随机数加一个赋值就完了但里面藏着两个坑。第一个坑是随机种子。很多新手第一次写游戏会发现每次启动程序食物都在同一个位置严重的时候连蛇的移动模式都一模一样。这是因为rand()函数的随机序列是固定的不设置随机种子就等于看一场反复重播的电影。解决办法是在程序启动时调用srand((unsigned)time(NULL))把当前时间当成种子。这样每次运行的随机序列都不一样。第二个坑是食物可能生成在蛇身上。如果只写一行grid[rand_y][rand_x] FOOD;不做任何检查食物偶尔会直接出现在蛇身所在格子里游戏画面里食物和蛇身重叠玩家根本看不见食物在哪。正确做法是循环生成坐标直到选中的格子是空地为止。void spawn_food(Map *map) { int x, y; while (1) { x rand() % COL; y rand() % ROW; if (map-grid[y][x] EMPTY) { map-grid[y][x] FOOD; break; } } }不过这个while(1)也不是完全保险的当蛇长到接近占满全图时随机数可能连续多次落进蛇身极端情况下会出现很长的等待。我在1.20里加了一个兜底逻辑连续尝试200次还找不到空地就扫描全图找第一个空位确保游戏不会在最后关头卡死。这个优化平时几乎不会触发但真正触发的时候能救一次游戏体验。2.4 速度、分数与难度参数的平衡难度控制是贪吃蛇可玩性的核心。速度太慢玩家等得发困速度太快新手连转向都来不及。我在1.20里把延时参数拆成了三档这样不同水平的人都能找到自己的节奏。速度档位核心就是控制每帧之间的延时用Sleep函数或者usleep函数都能实现。我实测下来简单档用150毫秒延时每秒大约移动6.7格新手完全可以慢慢思考下一步怎么走普通档用100毫秒每秒10格这个速度最接近经典诺基亚版贪吃蛇的手感困难档用60毫秒每秒大约16.7格基本就是考验肌肉记忆了。食物得分方面我固定了每吃一个食物加10分。这样算分非常直观吃10个食物就是100分玩家不用换算就能马上算出自己的成绩。最高分存档则利用了文件读写游戏结束时把本局分数和本地文件里保存的历史最高分比较如果刷新纪录就写回文件。这个功能虽然简单但极大提升了游戏的“再来一局”动力。还有一个隐藏参数是蛇的初始长度和初始位置。我设成3格长起点在地图中心初始方向朝右。为什么朝右因为玩家的左手通常放在WASD上右手放在方向键上而右侧是“前进”这个动作最自然的映射。开局后玩家只需要按一次向上或向下就能避开迎面刷新出来的第一颗食物这个小细节能让上手体验顺滑不少。3. 实操实现手写一个贪吃蛇1.203.1 开发环境与工程骨架先说环境。1.20这一版我用的是纯C语言没有引入第三方库。Windows下用Visual Studio新建空项目或者用MinGW的gcc命令编译都行Linux和macOS下用gcc直接编译。唯一要注意的是不同平台对终端控制的函数支持不一样比如清屏指令Windows用system(cls)Linux和macOS用system(clear)这部分我会在第3.3节里专门封装。工程文件我拆成了四个部分main.c负责游戏主循环和流程控制snake.c实现蛇的移动与碰撞food.c管食物生成input.c专门处理键盘输入。还配了一个game.h头文件把共用的结构体、枚举和函数声明集中放在一起。这样拆的好处是思路清晰每个文件只干一件事后续改难度参数或者加道具系统都能快速定位到对应位置。编译命令也很简单Windows下如果用的是gcc一条命令就够了gcc main.c snake.c food.c input.c -o snake_game我在开发过程中一直遵循一个原则代码能编译通过只是起点运行过程中不崩溃、不卡死、不出现逻辑漏洞才是真正要打磨的地方。控制台程序没有图形界面那样的断点调试体验所以我在关键函数里加了大量调试输出用printf打印每一步的坐标和状态测试完再统一注释掉。3.2 核心代码实现初始化、移动、渲染初始化部分要做三件事创建蛇身链表、初始化地图二维数组、生成第一颗食物。蛇的初始节点我用一个循环创建从头部向后依次生成。地图初始化时先把所有格子的值设成EMPTY再把蛇身经过的格子标记成SNAKE最后调用spawn_food生成食物。void snake_init(Snake *s) { int start_x COL / 2; int start_y ROW / 2; s-length 3; s-dir RIGHT; s-score 0; s-state 0; s-head NULL; s-tail NULL; for (int i s-length - 1; i 0; --i) { Node *n (Node*)malloc(sizeof(Node)); n-x start_x - i; n-y start_y; n-next NULL; if (s-tail NULL) { s-head n; } else { s-tail-next n; } s-tail n; } }渲染函数我写得比较朴素。早期的版本用的是system(cls)加循环打印后来发现闪屏问题很严重就改成了用光标定位直接覆盖写。Windows下可以用SetConsoleCursorPosition定位光标再逐字符打印地图。这样每次刷新只覆盖有变化的部分画面稳定了很多。void render(Map *map, Snake *s) { system(cls); for (int y 0; y ROW; y) { for (int x 0; x COL; x) { char ch ; switch (map-grid[y][x]) { case SNAKE: ch #; break; case FOOD: ch *; break; } putchar(ch); } putchar(\n); } printf(score: %d\n, s-score); }渲染输出的字符选择也有一点讲究。我用#表示蛇身*表示食物这样在黑色背景下对比度足够高截图发出去也不会因为颜色丢失导致看不清楚。如果想加颜色Windows下可以用SetConsoleTextAttribute临时改字符颜色但这会增加平台差异所以1.20默认用纯字符方案把颜色开关留成了编译选项。3.3 方向输入处理方向键与扫描码控制台游戏里最磨人的就是键盘输入。普通字符键比如WASD直接用getch就能读到ASCII码但方向键不一样。方向键属于特殊功能键按下后系统会送出两个字节第一个字节通常是0xE0或0x00第二个字节才是真正的扫描码。如果只调一次getch读到的只有0xE0根本分辨不出用户按的是上还是下。int read_direction(void) { if (!_kbhit()) return -1; int c _getch(); if (c 0xE0 || c 0x00) { c _getch(); switch (c) { case 72: return UP; case 80: return DOWN; case 75: return LEFT; case 77: return RIGHT; } } else { switch (c) { case w: case W: return UP; case s: case S: return DOWN; case a: case A: return LEFT; case d: case D: return RIGHT; case : return PAUSE; } } return -1; }这段代码是Windows下的实现依赖conio.h里的_kbhit和_getch。Linux和macOS下这两个函数不可用需要改用termios设置原始模式让终端不经过行缓冲直接读取字符。因为平台差异太大我把输入部分单独封装成input.c在头文件里用条件编译把两套实现隔离起来。玩过那种老式终端游戏的人应该能体会到输入处理的优劣直接影响游戏手感。如果每帧只读一次输入快速连按方向键时蛇只会响应最后一次按键不会出现连续拐两个弯的情况。如果不对输入做节流键盘缓冲里积压的按键会被逐一处理玩家明明是轻轻按了一下蛇却在下一瞬间连拐两次直接一头撞上自己的身子。这个坑我在1.10版本里踩得特别深后面统一改成“每帧只取最新输入”的策略才解决。3.4 版本迭代记录与心得我一直觉得看一个项目的版本历史比看最终代码更有意思所以把1.20诞生的过程整理成了表格。版本核心变化解决的问题1.0蛇能移动、能吃食物、撞墙结束可运行的完整骨架1.1增加空格暂停与继续玩家思考时没有喘息空间1.2禁止180度反向转向快速按反方向导致蛇瞬间自杀1.3显示实时分数游戏过程没有反馈1.5重构移动函数统一碰撞逻辑头部与尾部判定混乱1.10增加最高分本地存档重开游戏后成绩清零没有目标感1.15增加三档难度选择固定速度无法匹配不同水平的玩家1.20优化渲染刷新、重构输入处理画面闪屏、按键堆积影响手感这份迭代记录里的每一项都是我实际遇到问题之后才动手修的没有哪个功能是一拍脑袋加上的。做项目就是这样与其憋一个大版本憋到天荒地老不如小步快跑每改一轮就实玩几次手感不对马上调整。1.20能给人“这游戏挺稳”的印象靠的就是这20轮一点一点的打磨。4. 常见问题与调试实录4.1 方向键读取失败第一字节0xE0网上搜贪吃蛇C语言教程评论区问得最多的问题就是“方向键没反应”。90%的情况都是因为只读取了一个字节。方向键按下后系统发送的是双字节序列第一字节0xE0或0x00是前缀第二字节才是上下左右的扫描码。你只调一次_getch拿到的永远是前缀自然什么方向都识别不了。解决办法我在input.c里已经演示过了先读第一个字节如果是0xE0或0x00就再读第二个字节。另一个容易忽略的细节是读第二个字节之前必须调用_kbhit确认缓冲区里有内容否则程序会卡在getch那里等输入整个游戏循环就停住了。还有一个小坑是数字键盘的方向键。数字键盘的2、4、6、8在NumLock关闭时也会产生方向扫描码但第一字节可能是0xE0也可能是0x00不同电脑还不一样。所以我直接用ASCII码分支处理WASD扫描码分支处理主键盘方向键数字键盘方向键不额外支持省了一批兼容性问题。4.2 蛇头撞自己却“不判定”扣细节的碰撞检测自撞判定是贪吃蛇逻辑里最容易出bug的地方而且出bug的方式都很隐蔽。最常见的问题是蛇头移动后的新位置与当前尾巴位置重叠程序立刻判定游戏结束但玩家明明看到尾巴已经缩走了这波属于误判。原因就在于前面说的顺序问题先检查自撞还是先移动必须先移动再检查否则尾巴还在原位那个格子还没空出来。另一个隐蔽问题出在食物触发的长度变化上。吃到食物后蛇身加长如果食物生成的位置紧贴着蛇头新头部插入链表后原有的蛇身节点坐标不变自撞判定遍历链表时会把旧头部也当成身体导致完全没有碰撞却报撞车。我在排查这个问题时加了一个“排除头部自身”的判断条件遍历从head-next开始而不是从head开始。如果游戏偶尔出现“穿身而过”的反向bug也就是蛇头明明扎进了身体却还能继续走那多半是移动函数里的返回状态没被主循环正确处理。调试这类问题最有效的办法是把每一步的蛇头坐标和方向打印出来跟着输出走一遍很快就能定位是碰撞函数本身的问题还是外层调用逻辑的问题。4.3 食物总是刷在蛇身上食物生成到蛇身上这个问题新手几乎必踩。写代码时很容易默认地图上大部分格子都是空的随机一下总能选中空地但实际上蛇身会越来越长当长度超过地图面积的1/3时随机命中的概率就开始明显变高。我见过一个版本蛇吃到十几颗食物后食物生成点反复落在蛇尾附近玩家一转头就撞上去了体验差到极点。解决思路就是生成后检查格子状态不是EMPTY就重新生成。这看起来没有技术含量但真的能彻底消灭“食物隐身”问题。前面提到的200次重试上限也别忘了加不然地图被蛇占满的时候这个函数可能变成一个死循环。有些项目会把食物生成和蛇身长度挂钩比如蛇每长10格食物就增加一个。这种设计在功能上确实丰富了但会让逻辑复杂不少。1.20没有采用这个方案一是保持规则简洁二是让代码始终能作为教学样本不至于被额外规则淹没。4.4 画面闪屏与输入堆积控制台程序的闪屏问题非常典型尤其是用system(cls)清屏再重新打印的方式每次刷新都有一次明显的白屏闪烁。帧率越高闪得越厉害连续玩上几分钟眼睛就开始抗议了。我的优化方案是放弃整体清屏改用光标定位。Windows下先用GetStdHandle拿到标准输出句柄再用SetConsoleCursorPosition把光标移到地图起点之后逐字符覆盖打印。这样每次刷新不会清除整块屏幕只有蛇尾消失和食物出现的格子会被重写闪烁问题基本消失。Linux下可以用ANSI转义序列\033[H实现同样效果。输入堆积则是在按键读写节奏上出了问题。很多版本的输入处理是“只要检测到按键就立刻响应”这会导致玩家快速按两下方向键时蛇在一帧内连续转向两次逻辑上合法但手感上非常难受。1.20的做法是主循环每帧只调用一次read_direction只取最新的一次输入其余输入全部丢弃。如果玩家在100毫秒的帧间隔里按了三次方向键程序只响应最后一次反而能避免很多误操作。最后提一个调试技巧写控制台游戏时别急着把printf全删掉。我在snake_move和input.c里留了一个DEBUG开关编译时加-DDEBUG就能开启所有坐标和按键的日志输出。这个开关我写代码时天天用发布时才关掉。有了它很多诡异的问题根本不用猜看日志就一清二楚。我做这个项目最大的体会是编程能力的提升往往不在于写过多难的算法而在于有没有把一件看起来简单的事反复打磨到极致。贪吃蛇的每个模块单独拎出来都不难但把它们组装成一个流畅、稳定、好玩的游戏需要的恰恰是那份认真对待边界条件和细节的耐心。下一次如果让我从零开始做蛇我想试试加一个AI自动寻路模式让电脑控制蛇自己去吃食物那又会是一轮新的踩坑和优化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑