ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C语言从零实现贪吃蛇:核心知识点与避坑指南

用C语言从零实现贪吃蛇:核心知识点与避坑指南 1. 为什么我建议每个C语言初学者都做一次贪吃蛇先说结论如果只让我推荐一个C语言练手项目我会选贪吃蛇而不是图书管理系统也不是学生成绩管理系统。原因很简单贪吃蛇这个项目把C语言最核心的知识点全部串起来了而且它有一个其他项目不具备的优势——反馈极快。你写一个图书管理系统程序跑起来之后无非是增删改查界面是黑底白字的控制台老实说很难从里面获得“我正在做一款游戏”的成就感。但贪吃蛇不一样你敲完代码编译运行屏幕上真的有一条蛇在动真的会因为吃到一个食物变长真的会因为你操作失误而撞墙死亡。这种即时反馈对编程学习的激励作用比任何教科书都管用。从技术角度讲贪吃蛇覆盖的知识面也非常扎实。要完成一个功能完整的贪吃蛇你至少要接触到以下几个技术点链表或数组的动态数据管理用来表示蛇身的每一节循环队列的思想蛇移动时实际上是尾部出队、头部入队键盘输入的非阻塞检测不能等玩家按下按键才继续跑坐标运算与边界检测本质上是一个二维坐标系里的数学判断游戏状态机的设计菜单、运行、暂停、死亡这些状态之间的切换内存管理与指针操作尤其是用链表实现时malloc/free是少不了的这些知识点单独学的时候每个都很抽象。链表就是“一个结构体指向另一个结构体”键盘输入就是“scanf”但你不知道这些到底能拿来干什么。直到你开始做贪吃蛇你才发现链表是用来装蛇身体的非阻塞输入是为了不让游戏卡住坐标运算是为了判断蛇有没有撞上墙。知识一下子就有了落脚点。这篇博客不是教你照着抄一份完整代码而是拆解我做贪吃蛇时的完整思路怎么设计数据结构、怎么拆分模块、怎么处理各种边界情况、踩了哪些坑。我会给出关键代码片段和设计理由你自己动手补齐剩下的部分这样学到的东西才真正是自己的。2. 动手前的核心设计数据结构与模块边界我见过很多人在写贪吃蛇之前不设计数据结构上来就写main函数写着写着发现蛇动不了、食物生成位置跟蛇体重叠、暂停不好做然后整个代码推倒重来。这个项目虽然不大但贪吃蛇的复杂度足够让你在没有任何设计的情况下写成一团乱麻所以先花半小时把数据结构想清楚后面能省两小时。2.1 蛇身的数据结构数组还是链表这是做贪吃蛇问得最多的问题。教科书上通常推荐用链表因为蛇身长度是动态变化的吃一个食物就多一截用链表不用预先知道最大长度。但实际上两种方案都能做各有各的适用场景。我建议初学者先用定长数组原因非常实际贪吃蛇有一个天然的长度上限就是地图能容纳的最大格子数。比如你做一个20x20的地图加上边框蛇最长也不会超过400节所以你可以直接定义一个足够大的数组#define MAX_SNAKE_LEN 400 typedef struct { int x; int y; } BodyNode; BodyNode snake[MAX_SNAKE_LEN]; int snake_len;数组方案的优势是写起来简单访问第几节蛇身直接snake[i]就行不需要处理malloc、free不会有内存泄漏风险。缺点是如果你没想清楚怎么维护“蛇头是数组哪个位置”代码很容易把自己绕晕。链表方案则更“正统”每个节点动态分配typedef struct SnakeNode { int x; int y; struct SnakeNode *next; } SnakeNode; SnakeNode *head; // 蛇头 SnakeNode *tail; // 蛇尾链表的好处是删除蛇尾非常快移动时只要free(tail)然后把新节点插到头前面就行。但链表的麻烦在于你要维护两个指针而且调试的时候一旦出现野指针整个程序直接崩溃对初学者并不友好。我自己做的时候用的是数组加循环队列的思路记录蛇头在数组中的下标head_idx蛇尾就是(head_idx snake_len - 1) % MAX_SNAKE_LEN。蛇移动时先根据head_idx推出新的蛇头位置然后判断这个位置是否合法合法就把head_idx更新同时把新蛇头的数据写进数组。// 源方向前进一步计算新蛇头 int new_head_x snake[head_idx].x; int new_head_y snake[head_idx].y; switch (dir) { case UP: new_head_y--; break; case DOWN: new_head_y; break; case LEFT: new_head_x--; break; case RIGHT: new_head_x; break; } // 蛇尾位置的计算 int tail_idx (head_idx snake_len - 1) % MAX_SNAKE_LEN;核心思想是数组不删除数据只是通过下标标记当前蛇头和蛇尾的位置。蛇每次移动相当于蛇尾那个格子标记作废蛇头那个格子新增一个这种环形覆盖的方式理解起来比链表直观的多而且性能一点都不差。2.2 地图、方向、分数的常量设计地图不用做成二维数组存整个画面。控制台里你只需要知道哪些位置是墙、哪些位置是蛇身、哪些位置是食物、哪个位置是空地。我处理的方法是每个格子用一个字符表示画面渲染的时候遍历输出就行#define ROWS 20 // 地图行数不含边框 #define COLS 40 // 地图列数不含边框 char board[ROWS 2][COLS 2]; // 加边框方向的定义我建议用枚举而不是魔法数字。贪吃蛇的方向只有上下左右四个但如果你直接在代码里写if (dir 1)过两周你自己都忘了1代表什么typedef enum { DIR_UP 0, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Direction;还有一个很关键的设计贪吃蛇不能直接掉头。在游戏里玩家按了左键下一秒又按右键如果允许直接反向蛇会瞬间穿过自己的身体。这个判断要在按键处理时做// 禁止相反方向掉头 if ((dir DIR_UP key DIR_DOWN) || (dir DIR_DOWN key DIR_UP) || (dir DIR_LEFT key DIR_RIGHT) || (dir DIR_RIGHT key DIR_LEFT)) { // 忽略这次按键 }我见过不少人把掉头判断放在蛇移动逻辑里结果边界情况处理起来特别别扭。其实按键检测阶段就过滤掉非法方向后面移动逻辑就不需要考虑这种特殊情况了设计上更干净。2.3 模块划分别把所有代码堆进main函数初学者写项目最容易出现的情况是一个main函数几百行从上到下一路写到底。贪吃蛇的功能如果全部塞进main你会得到一个巨型函数没法调试也没法复用。合理的模块划分应该是这样的模块职责关键函数main.c游戏初始化、主循环、状态切换main, game_runsnake.c蛇的移动、增长、碰撞检测snake_move, snake_growfood.c食物的生成、位置判断food_generate, food_eatdraw.c地图渲染、得分显示draw_board, draw_scoreinput.c非阻塞按键检测get_key_press每个模块声明放在对应的头文件里通过接口互相调用。比如draw.c需要知道蛇在什么位置那就用结构体指针把蛇的数据传进去而不是让draw.c直接访问全局变量。这样以后你想把控制台版本改成图形界面版本只需要替换draw.c就行游戏逻辑完全不用动。模块化还有一个实际好处调试的时候你能针对单个模块写测试。比如我只想测试蛇移动逻辑对不对写个小main调snake_move()在控制台打印蛇每个节点的坐标比从完整游戏里复现一个bug快得多。3. 核心实现拆解从游戏循环到碰撞检测设计做完接下来是真正的实现环节。很多人对贪吃蛇的第一个疑问是怎么让蛇自动往前走这听起来像是一个很玄学的问题但实际上就是“每隔一段时间给蛇头换个位置”。这个“每隔一段时间”就是游戏循环。3.1 游戏循环一个while和一个sleep撑起整个世界控制台版的贪吃蛇不需要复杂的定时器机制最简单的做法是主循环里调用一个睡眠函数控制帧率while (game_state ! STATE_OVER) { handle_input(); // 检测按键 update_snake(); // 蛇移动 check_collision(); // 碰撞检测 draw_frame(); // 渲染画面 Sleep(100); // 每100毫秒一帧 }这里的Sleep时间是游戏速度的核心参数。100毫秒相当于每秒10帧新手刚开始玩的时候会觉得很舒服等你想增加难度把这个值降到50毫秒游戏难度立刻翻倍。实际操作中你会发现Sleep的粒度在不同平台不一样Windows上用Sleep()毫秒为单位Linux上要用usleep()并且单位是微秒这些都是移植时会遇到的细节。但这里有个致命问题Sleep(100)会让整个程序每帧固定等待100毫秒但handle_input()如果用的是阻塞式输入比如getchar()蛇就会在等待玩家按键的时候停住。玩家不按键游戏就不前进——这根本不是贪吃蛇该有的样子。所以键盘检测必须是非阻塞的稍后我会专门讲这一点。3.2 蛇的前进头部插入、尾部删除或增长蛇移动是整个程序的核心逻辑别的都是在围绕它转。基于我前面设计的循环队列结构蛇前进分两种情况情况一没有吃到食物。蛇保持原长度移动相当于头部前进一步、尾部缩短一格。void snake_move(Direction dir) { // 计算新蛇头位置 int new_x snake[head_idx].x dx[dir]; int new_y snake[head_idx].y dy[dir]; // 蛇头前移 head_idx (head_idx 1) % MAX_SNAKE_LEN; snake[head_idx].x new_x; snake[head_idx].y new_y; // 如果没吃到食物原蛇尾格子作废 // tail_idx 向前推进一位即可 }这里有一个思维的转变数组里的数据其实没有被删除只是下标移动了。可能你的数组里存着旧蛇头的位置但那个位置已经不在head_idx到tail_idx这个循环区间内了渲染的时候就不会画出来。情况二吃到食物。蛇长度加1尾部不动只增加头部。if (ate_food) { snake_len; // 食物被吃掉后重新生成新食物 food_generate(); }实现的巧思在于由于食物生成时已经避开了蛇身占据的格子蛇每次移动至少会空出一个格子来安放新生长的那一节——你不需要在代码里找蛇的“最后一个节点”然后克隆它。吃了一个食物就是长度1然后尾部暂时不需要收缩下一次移动时因为长度还比原先大1就会多保留一个尾节点。3.3 食物生成随机位置的避让逻辑食物生成看起来简单就是随机坐标但有一个细节特别容易出bug食物不能生成在蛇身上。如果食物的格子被蛇身覆盖蛇去吃自己身体不行。所以生成食物后要先遍历蛇身检查是否重叠如果重叠就重新生成。void food_generate() { bool food_on_snake true; while (food_on_snake) { food.x rand() % COLS 1; food.y rand() % ROWS 1; food_on_snake false; for (int i 0; i snake_len; i) { int idx (head_idx - i MAX_SNAKE_LEN) % MAX_SNAKE_LEN; if (snake[idx].x food.x snake[idx].y food.y) { food_on_snake true; break; } } } }有个学弟问过我如果地图几乎被蛇占满了while循环会不会死循环理论上存在这个可能但实际情况中地图200个格子蛇能占到一半以上的时候游戏已经接近尾盘了你几乎不可能活到蛇占满地图。你如果实在担心这个问题可以在循环里加一个尝试次数上限比如100次超过就直接判定游戏胜利——这个细节处理得好还会成为你代码里的一个小亮点。随机数的种子也要注意。很多初学者直接rand()不设种子每次启动游戏食物生成的位置完全一样。正确做法是在main函数里用当前时间做种子#include time.h srand((unsigned int)time(NULL));3.4 碰撞检测撞墙和撞自己两个都别漏碰撞检测的边界情况比想象中多。第一是撞墙也就是蛇头超出地图范围if (new_x 0 || new_x COLS 1 || new_y 0 || new_y ROWS 1) { game_state STATE_OVER; return; }注意这里的边界判断要和你地图的实际尺寸保持一致。我见过不少代码坐标从0开始计数墙也占了一个格子结果蛇明明还没碰到墙就判定死亡或者穿墙出去了都在边界值上差了一位。第二是撞自己。这里有个容易出错的地方蛇头要避开的是“除蛇尾外的自己”。为什么因为蛇移动和碰撞检测如果都在同一帧里执行蛇尾那格会在这一帧里往前移动实际上一瞬间蛇头占据的位置可能和蛇尾原来的位置一起被蛇本身占用。如果检测到蛇头碰到蛇尾就算死亡会造成一种非常奇怪的死亡蛇绕了一个圈蛇头碰到了自己的尾巴尖——这其实是合法的因为尾巴同时也在往前挪。具体处理方式是// 碰撞检测时跳过最后一个节点蛇尾 int check_times snake_len - 1; for (int i 1; i check_times; i) { // i0是蛇头自身 int idx (head_idx - i MAX_SNAKE_LEN) % MAX_SNAKE_LEN; if (snake[idx].x new_x snake[idx].y new_y) { game_state STATE_OVER; return; } }当然这个逻辑和你碰撞检测的调用时机有关如果你先移动蛇再检测碰撞蛇尾位置已经更新了那么“碰到蛇尾”就是不可能的如果你先检测碰撞再移动就必须跳过蛇尾。理清楚这层先后关系你就不会写出“莫名其妙死亡”的bug。3.5 按键输入非阻塞键盘检测的实现这个部分是控制台版贪吃蛇最容易卡住的点。标准库里的getchar()是阻塞式输入程序执行到这里会一直等玩家按键整个游戏就停了。你需要的是“检测当前有没有按键被按下如果有得到这个键的值如果没有立刻返回”。Windows平台用_kbhit()和_getch()Linux平台要用termios设置终端为非阻塞模式// Linux/macOS 非阻塞输入 #include termios.h #include unistd.h #include fcntl.h int kbhit(void) { 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; }这段代码每次检测都要设置和恢复终端状态如果你追求效率可以把终端的非阻塞设置放在主循环之前做一次游戏结束后再恢复。但我建议初学者用前面的写法简单直观性能损失在这个场景下完全可以忽略。Windows平台就简单太多了#include conio.h if (_kbhit()) { int key _getch(); // 处理方向键需要读取两个字节 if (key 224 || key 0) { key _getch(); switch (key) { case 72: dir DIR_UP; break; case 80: dir DIR_DOWN; break; case 75: dir DIR_LEFT; break; case 77: dir DIR_RIGHT; break; } } }注意Windows控制台的方向键是两字节的扫描码第一个字节是224第二个字节才是真正的方向键值。这里如果不加处理你会发现按上下左右根本没反应或者出现奇怪的字符。这是一个非常经典的坑。4. 我踩过的坑调试与优化实录任何项目做完最宝贵的资产就是踩坑的经历。这些坑在教科书里不会写在网上的完整代码里也看不出来只有你自己写了一边调一边debug才能理解它们是怎么发生的。这里分享我自己的四个真实教训。4.1 蛇为什么“倒着走”了第一次实现移动逻辑时我的蛇能走但是方向完全反了。向上按蛇往下走向左按蛇往右走。排查了很久最后发现问题出在我给方向定义的坐标增量上// 错误版本 const int dx[4] {0, 0, -1, 1}; const int dy[4] {-1, 1, 0, 0};我看着这组数怎么都对dx是水平方向dy是垂直方向DOWN的dy应该是1向下移动y坐标增加。但我的枚举定义是DIR_UP0, DIR_DOWN1坐标设置是dx[Direction]数组下标和枚举没对上。检查的时候只看数组内容没有验证枚举值和数组下标的对应关系。教训是数据定义和数据使用要放在一起看不能只看其中一半。我把方向状态机和坐标增量改成用结构体封装一眼就能检查对应关系typedef struct { int dx; int dy; } DirVec; DirVec vec[4] { {0, -1}, // UP {0, 1}, // DOWN {-1, 0}, // LEFT {1, 0} // RIGHT };这样每个方向对应哪个坐标增量一目了然不会再出现对不上的问题。4.2 画面闪烁和光标残留控制台版游戏最容易被嫌弃的就是画面乱闪。贪吃蛇每次刷新都要清屏重画如果用system(cls)速度慢不说还会导致整个控制台闪烁玩起来眼睛特别难受。我的解法有两个层面的优化第一光标定位代替清屏重绘。把光标移动到固定坐标再输出只重画变化的部分而不是每次都清空整个屏幕// Windows 下定位光标 #include windows.h void goto_xy(int x, int y) { COORD coord; coord.X x; coord.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); }第二隐藏光标。游戏过程中光标一直闪烁真的很碍事隐藏掉体验立刻提升一个档次void hide_cursor() { CONSOLE_CURSOR_INFO cursor; cursor.bVisible FALSE; cursor.dwSize 1; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cursor); }Linux下的做法则用ANSI转义序列printf(\033[?25l); // 隐藏光标 printf(\033[?25h); // 显示光标这些细节不影响游戏逻辑但对体验的影响巨大。第一次看到画面不闪烁、光标也不乱跳的时候我真心觉得之前一直忍受“闪瞎眼”的贪吃蛇是自虐。4.3 暂停功能的内部状态陷阱做暂停功能时我一开始的思路是检测到空格键就进入暂停再按空格继续。但马上遇到一个问题玩家按住空格不放会反复触发暂停和继续游戏在暂停和运行之间疯狂切换。解决方案是边缘触发——只有当按键从“没按下”变成“按下”的那个瞬间才响应而不是只要按键处于按下状态就一直响应。实现起来很简单bool last_key_space false; if (kbhit()) { int key getch(); if (key ) { if (!last_key_space) { game_state (game_state STATE_RUNNING) ? STATE_PAUSED : STATE_RUNNING; } last_key_space true; } else { last_key_space false; } }这个“边缘触发”的思想在很多真实项目里都非常重要——按键检测、按钮响应、中断处理凡是“按住会重复触发”的地方都需要考虑。贪吃蛇虽然小但能把这种机制在自己代码里实现一遍以后碰到类似需求就有肌肉记忆了。4.4 内存管理和越界一条蛇引发的噩梦用链表方案的时候我在蛇死亡后的清理阶段犯过一个典型的错误。游戏结束时我写了一个释放内存的函数void free_snake() { SnakeNode *cur head; while (cur ! NULL) { free(cur); cur cur-next; // 这里cur已经被free了 } }看起来没问题实际上在free(cur)之后cur-next是一个已经被释放的内存区域的访问。在某些情况下系统可能还会让你侥幸读到正确值但这是一个不确定行为随时可能崩溃。正确的写法是先保存next指针再释放当前节点void free_snake() { SnakeNode *cur head; while (cur ! NULL) { SnakeNode *next cur-next; free(cur); cur next; } }这个bug藏了很久才被发现因为正常运行的时候内存大概率不会被立刻改写表现不出来。但它就像一颗地雷说不定哪个用户的内存布局不一样就炸了。用数组方案就没有这个烦恼这也是我说新手可以先从数组方案入手的一个重要原因。5. 还能往哪走从“能玩”到“玩得漂亮”一个能跑起来的贪吃蛇只是开始如果你想拿它当课程设计、面试项目或者单纯想多学点东西下面这些扩展方向都是很好的进阶练习。每个方向都有不同的技术侧重点。5.1 难度系统动态调整游戏速度最简单也最自然的扩展是蛇每吃一个食物移动速度就加快一点。实现上就是动态调整Sleep时间int speed 100; // 初始速度 if (ate_food) { if (speed 30) { speed - 5; } }从100毫秒逐步降到30毫秒的加速过程能明显感知到难度曲线上升。这个扩展几乎不会引入新的复杂度但对游戏性提升非常明显。更进一步可以加等级系统每吃5个食物升一级屏幕上显示当前等级和速度。5.2 障碍物地图与关卡在游戏区域里手动放置或者随机生成一些障碍物蛇碰到障碍物就死亡。这个扩展用到的技术是地图数据的扩展你要额外维护一张障碍物地图生成障碍物时避开蛇的初始位置和食物位置。#define WALL # #define SNAKE #define FOOD * #define EMPTY 地图的渲染逻辑需要增加一个图层概念先画障碍物再画食物最后画蛇身。覆盖关系要理清楚否则会出现食物被蛇遮住但还能吃到的奇怪视觉bug。这个方向很适合作为课程设计因为它在贪吃蛇基础上加了“关卡”的概念可以做成十几个关卡每个关卡的地图不同、障碍物排布不同展示的时候能讲的空间就大得多。5.3 排行榜与文件存储文件操作是C语言课程的必考内容单纯读文件写文件很枯燥但如果你把排行榜功能做进贪吃蛇文件操作就成了游戏不可分割的一部分typedef struct { char name[20]; int score; int level; } PlayerRecord; void load_records(PlayerRecord *records, int *count); void save_records(PlayerRecord *records, int count); void sort_records(PlayerRecord *records, int count);游戏结束后输入玩家昵称分数按从高到低排序写入二进制文件或者文本文件。下次启动游戏时读取排行榜并显示。这个扩展用到文件打开/写入/关闭、结构体数组排序、可能还要考虑文件不存在时的容错处理是很好的综合练习。5.4 从控制台到图形界面如果你学完C语言还想继续深入可以考虑用EasyX图形库Windows或者SDL2跨平台库把贪吃蛇升级成图形界面版本。图形库的好处是摆脱控制台的格子限制可以用像素级别的坐标控制蛇的移动画面流畅度和视觉效果完全不一样。但要注意图形版的贪吃蛇和控制台版的核心逻辑是通用的。你之前设计的snake_move()、碰撞检测、食物生成这些模块都不需要改要做的是把输入检测、画面渲染这两个模块替换掉。这正是我前面强调模块化的意义——好的设计能让你在换一个展示层的时候不需要推翻重来。5.5 AI贪吃蛇一个更强的挑战贪吃蛇还能做AI自动寻路。写一个AI让它自己找食物、自己避开障碍、永不撞墙这是一个经典的算法挑战。最简单的AI策略是“贪心”每一步都朝食物方向移动但要注意避免走进死路。进阶的AI会用广度优先搜索BFS寻找路径再用一些策略避免蛇把自己困住。这个方向的难度完全超越了C语言本身涉及算法设计和状态搜索做出来的东西非常炫酷——你可以让AI跑一个几千分的记录出来然后用人工操作对比感受一下被AI碾压的震撼。让贪吃蛇成为你的C语言里程碑做完贪吃蛇之后我最大的感受不是“我会做贪吃蛇了”而是“我终于能把C语言的知识点串起来了”。在这之前指针是指针、结构体是结构体、循环是循环它们在我脑子里像一袋散装的珠子。做完贪吃蛇袋子里有了一条线把所有珠子串成了一串。之后我再去看链表、看队列、看文件操作都能立刻联想到“这可以用在我的游戏里做某个功能”。如果你正在学C语言给自己定一个小目标两到三周每天写一两个小时用纯C语言做出来一条能玩的贪吃蛇。卡住不要紧上网查、翻书、问同学都行但一定不要直接抄一整套完整代码。自己想出来的每一步怎么实现哪怕很笨拙收获也比抄十遍代码大得多。等你亲手写完、编译运行、看着自己写的蛇在屏幕上吃掉食物、撞墙死亡的那一瞬间你会觉得之前所有的debug都值得。
返回列表