多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C语言课程设计贪吃蛇全攻略:从链表到碰撞检测

C语言课程设计贪吃蛇全攻略:从链表到碰撞检测 简介一份面向高校C语言课程设计的贪吃蛇项目报告适合正在完成C语言实践作业或需要参考完整设计流程的学生。文档以课程设计报告形式呈现涵盖课程设计目的、实验环境、预备知识与设计要求并围绕贪吃蛇游戏讲解了程序功能、整体设计思路、数据结构设计、程序流程图、各模块功能说明及带注释的程序源代码同时附有设计总结与参考资料。通过这份资料读者既能了解贪吃蛇游戏从界面布局、键盘控制到游戏逻辑的完整实现过程也能借鉴报告结构与写作格式用于完成自己的课程设计。资源为1个doc文档压缩包大小145KB内容以文字说明和C语言代码为主适合初学者对照学习。目前已有1060人学习下载是一份结构较为完整的课程设计参考样例。1. 为什么“C语言课程设计贪吃蛇”最后都被做成了链表题你把“贪吃蛇”三个字拆开看会发现它根本不是游戏而是把 C 语言里最常考的四样东西捆在了一个程序里数组管地图、链表管蛇身、指针管动态增长、键盘输入管实时交互。大多数课程设计选题里只有它能把“语法学会”和“程序能跑”这两件事同时检验到所以老师爱出学生也爱选。但很多人在这个题上栽跟头不是因为不会写游戏逻辑而是因为一开始就把蛇身存成了二维数组结果每走一步都要搬动一整串坐标地图稍大一点代码就乱成一团。这篇的内容就是顺着“数据结构怎么选、方向键怎么读、一帧动画怎么推进、文件怎么拆、答辩怎么答”这条线把整个流程走一遍。适合正在为课设选题发愁或者已经写完代码但说不清原理的人。2. 贪吃蛇的数据结构蛇身为什么更适合用链表而不是数组2.1 数组能描述地图却描述不了“蛇”贪吃蛇的地图本质上是一个二维数组这个没有争议。地图是固定的格子要么是空地、要么是墙、要么是食物、要么是蛇身用char map[H][W]就能表达清楚。但蛇不一样蛇是一条会变长的队列头前进一格尾巴跟上一格吃到食物以后尾巴不动整体长度加一。如果蛇身也用数组存比如SnakeBody body[MAX_LEN]那么每次移动都要把数组里所有元素往前平移一个位置再做尾部增减。这个操作在蛇长 50 的时候问题不大但它的复杂度是 O(n)而且你永远要处理“数组满了怎么办”“中间的坐标怎么挪”这种和游戏逻辑无关的杂事。链表天然适合“头插、尾删”这种操作模式每个节点只关心自己前后是谁蛇身增长只是多malloc一个节点。对比项数组存蛇身链表存蛇身头部插入需要整体后移O(n)修改头指针O(1)尾部删除直接改长度标记O(1)单向链表需找前驱O(n)长度上限需要预设MAX_LEN受堆内存限制无需预设指针考点无考察malloc/free、结构体嵌套答辩价值能跑能讲我一般会在课设里用链表但也会在报告里写清楚“尾部删除是 O(n)在 20×20 地图里蛇最多 400 节这个开销可以忽略”。这句话一出来老师就知道你不是只会背代码。2.2 蛇的节点结构和地图数据结构先定义节点和全局游戏状态。地图用 0 表示空地1 表示墙2 表示蛇身3 表示食物。#define WIDTH 20 #define HEIGHT 20 typedef struct SnakeNode { int x, y; struct SnakeNode *next; } SnakeNode; typedef struct { SnakeNode *head; SnakeNode *tail; int length; int alive; } Snake; char map[HEIGHT][WIDTH];SnakeNode里只有坐标和指向下一个节点的指针没有多余字段因为蛇身不需要知道自己的头尾方向只要知道自己在哪。Snake结构体保存头指针、尾指针和长度alive标记是否撞死。全局二维数组map每帧根据链表内容刷新一遍用来做碰撞检测和绘制。map和链表之间是“一主一从”的关系map是蛇的活动场地和碰撞依据链表是蛇身的真正数据源。每次移动后先改链表再根据链表把旧蛇身位置清成 0、新蛇身位置填成 2。2.3 移动一步的两种写法选一种讲清楚就行第一种是头插尾删申请一个新节点放在头部然后释放尾部节点。这种写法最符合“蛇往前爬一格”的直觉。void moveSnake(Snake *snake, int newHeadX, int newHeadY, int grow) { SnakeNode *node (SnakeNode *)malloc(sizeof(SnakeNode)); node-x newHeadX; node-y newHeadY; node-next snake-head; snake-head node; snake-length; if (!grow) { // 找到倒数第二个节点释放尾节点 SnakeNode *p snake-head; while (p-next ! snake-tail) { p p-next; } free(snake-tail); snake-tail p; p-next NULL; snake-length--; } }grow表示本轮是否吃到食物。为 1 时只在头部加节点尾不动为 0 时头尾同时前进长度保持不变。这里遍历链表找尾节点前驱是 O(n)但胜在代码直白答辩时容易解释。另一种常见做法是用数组模拟环形队列严格 O(1)很多在线教程用的就是这个适合地图偏大或蛇特别长的场景。我的建议是如果课设要求里明确写了“考察链表”就提交链表版如果没规定用环形队列更省心。2.4 一个很容易被问倒的问题为什么地图里还要再存一份蛇身既然链表里已经有蛇身坐标了map里为什么还要标 2因为碰撞检测要频繁查找“某个格子是不是蛇身”。链表查询是遍历每次移动都遍历一遍蛇虽然也能做但map用 O(1) 的下标访问就能回答同样的问题。int isSnakeBody(int x, int y) { return map[y][x] 2; }这个函数在下一步撞墙判定和食物生成时都会被反复调用。链表负责“蛇身在哪儿”map负责“某个格子有谁”各管一摊是这份代码里最值得写进报告的设计点。3. 实时键盘输入C 语言里怎么从 scanf 换到 kbhit 和 _getch3.1 为什么 scanf 在游戏循环里会卡死课程设计里最容易翻车的不是链表而是输入。scanf是一个阻塞函数程序运行到scanf会停下来等用户输入并按下回车然后才继续执行。但贪吃蛇要求“没按键时游戏继续跑有按键时立刻响应”这是两个完全不同的输入模式。// 错误示范游戏会卡在这一行 scanf(%c, ch);正确做法是使用非阻塞式按键检测。Windows 控制台下最常用的组合是kbhit()配合_getch()。kbhit()只负责探一下键盘缓冲区里有没有按键有就返回非 0没有立刻返回 0不会阻塞_getch()从缓冲区读一个字符也不需要在末尾按回车。3.2 方向键的读取一次按键实际上是两个字节_getch()读普通字母键一次返回一个字符但方向键是特殊键第一次调用会返回 2240xE0第二次调用才返回真正的方向码上 72、下 80、左 75、右 77。如果只读一次拿到 224 就丢掉方向码按键就会失效。#include conio.h typedef enum { UP 72, DOWN 80, LEFT 75, RIGHT 77 } Direction; Direction getDirection() { int ch _getch(); if (ch 224) { // 方向键前缀 ch _getch(); switch (ch) { case 72: return UP; case 80: return DOWN; case 75: return LEFT; case 77: return RIGHT; } } return -1; // 非方向键 }这里把方向码直接映射成枚举值后面移动逻辑里用枚举做判定比到处写魔法数字 72、80 清楚得多。如果用的是GetAsyncKeyState那套 API就没这个前缀问题但它的写法更啰嗦课设里我不太推荐。3.3 防“自杀”方向逆转是新手最容易忽略的坑运行中的蛇向右移动时玩家按下左键下一秒蛇头会直接钻进自己第二节身子当场死亡。这不是“玩家自己作死”是代码没有做反向过滤。判断方向是否合法的条件不是“新方向不等于当前方向”而是“新方向不等于当前方向的相反方向”。int isValidDirection(Direction cur, Direction next) { if (next -1) return 0; if (cur UP next DOWN) return 0; if (cur DOWN next UP) return 0; if (cur LEFT next RIGHT) return 0; if (cur RIGHT next LEFT) return 0; return 1; }这个函数每一帧在按键处理里调用一次。它能写对说明你理解了“蛇头的朝向”和“下一秒的位移”之间的关系。很多网上的代码会把这部分省略掉结果就是玩游戏时明明没撞墙却莫名其妙死了。3.4 主循环里 Sleep 参数怎么定变速规则游戏主循环必须控制节奏否则每秒刷新几千帧。Windows 下用Sleep(毫秒)控制每一帧的间隔。初始速度我一般设在 200 毫秒也就是每秒 5 格比较适合新手操作每吃 5 个食物减少 20 毫秒但最低不低于 80 毫秒。int delay 200; while (snake.alive) { if (kbhit()) { Direction next getDirection(); if (isValidDirection(curDir, next)) { curDir next; } } // 执行移动、碰撞、绘制 Sleep(delay); }Sleep的单位是毫秒放在循环末尾。注意它在windows.h里不是 C 标准库函数跨平台代码里需要用usleep或nanosleep替代。这块代码量不大但是整个游戏循环的骨架。4. 贪吃蛇一帧的完整逻辑移动、吃食物、撞墙与自交判定4.1 先预测新蛇头的位置再做判定游戏逻辑必须遵循一个顺序先算出“蛇头下一步会到哪个格子”再判断这个格子能不能去最后才真正移动。顺序反了会出现一种诡异的现象蛇已经撞墙了但画面上还往前走了一格才死。int newHeadX snake.head-x dx; int newHeadY snake.head-y dy;dx和dy由当前方向决定向右dx1, dy0向下dx0, dy1向左dx-1, dy0向上dx0, dy-1。这个映射也可以写成一个包含四个方向的数组用枚举做下标代码会更紧凑但课设里写成switch更好讲。4.2 撞墙判定与三种边界模式撞墙判定最简单检查新头坐标是否越界即可。但“越界”的定义取决于你选择的地图模式。边界模式说明撞墙处理封闭围墙地图最外一圈是墙碰到即死亡穿墙模式地图无墙左右打通、上下打通从另一侧出现障碍物模式地图内有固定石头碰到障碍物死亡课设里最常用的是封闭围墙。判定代码如下if (newHeadX 1 || newHeadX WIDTH - 1 || newHeadY 1 || newHeadY HEIGHT - 1) { snake.alive 0; return; }我建议把地图外圈预先填成墙也就是map[0][i] 1这样绘制围墙和判定撞墙用的是同一套逻辑。注意上面判断用的是WIDTH - 1因为最外圈已经是墙了蛇的合法活动范围是从 1 到 18。4.3 自交判定最大的坑蛇要踩的位置其实是“即将空出来”的尾巴判断蛇头是否撞到自己常规做法是遍历链表看有没有节点的坐标和新蛇头相同。但这里藏着一个很隐蔽的坑如果新蛇头的位置正好是当前蛇尾所在的位置这次移动其实是合法的。原因很简单蛇在前进尾巴会同时往前走一格原来尾巴占的那个格子在本轮动画结束后就空出来了。如果遍历时把尾巴也算进去蛇从长度 4 开始绕圈的时候就永远走不出第一步因为下一步总有一格是尾巴。int isHitSelf(Snake *snake, int x, int y) { for (SnakeNode *p snake-head; p ! NULL; p p-next) { if (p snake-tail) continue; // 跳过尾巴 if (p-x x p-y y) return 1; } return 0; }这个p snake-tail就是整个程序里最值钱的五六个字符。代码写完后你可以在报告里专门写一段测试用例说明构建一条长度 4 的蛇让蛇头向尾巴方向转弯验证它不会误判死亡。面试和答辩时主动讲出这个坑比背下一整段代码有用得多。4.4 吃食物与生成新食物随机坐标不能落在蛇身上食物生成函数的核心是“随机选一个空地”但不能无脑随机。第一次随机到蛇身上没关系跳过再随机一次。这个循环理论上可能跑很多次但在地图 20×20、蛇身不超过 100 节的前提下平均几次就出来了。void spawnFood() { srand((unsigned)time(NULL)); int fx, fy; do { fx rand() % (WIDTH - 2) 1; fy rand() % (HEIGHT - 2) 1; } while (map[fy][fx] ! 0); map[fy][fx] 3; }WIDTH - 2再加 1是为了让坐标落在 1 到 18 之间避开最外圈围墙。srand只需要在主函数里调用一次写在spawnFood里会导致短时间内生成的随机数序列重复食物总刷在同一个区域这是我踩过的老坑。整理一下一帧的完整判定顺序1. 计算新蛇头坐标 2. 判断是否撞墙撞墙则 alive 0 3. 判断是否撞到自身撞到则 alive 0 4. 判断是否吃到食物新蛇头坐标 食物坐标 5. 吃到食物头节点增长生成新食物 6. 没吃到食物头插 尾删 7. 刷新 map 数组重新绘制整个画面判定顺序不能乱。先撞墙再自交的原因在于墙外坐标不存在于链表里先做自交扫描只是白跑一遍先吃食物再移动会让蛇身先变长如果那一格本来也撞到了食物旁边的墙就会产生“先增长后死亡”的漏判。5. 可交付的贪吃蛇工程文件拆分、头文件与编译命令5.1 别把所有代码塞进 main.c课设代码如果只有 300 行全放在 main.c 里也能跑。但评审时老师会先看代码结构其次才看运行效果。一个 20×20 的贪吃蛇全写在一个文件里各部分功能交错答辩时很难按逻辑一条线讲下来。我一般会拆成三个文件文件职责改动频率main.c主循环、初始化调用几乎不动snake.h结构体定义、函数声明、地图常量增删接口时才改snake.c链表操作、移动逻辑、碰撞检测、绘制频繁改动snake.h是整个工程的门面别的文件依赖它。它的内容应该只有常量、类型定义和函数声明不能有全局变量定义。一个简单的头文件长这样#ifndef SNAKE_H #define SNAKE_H #define WIDTH 20 #define HEIGHT 20 typedef struct SnakeNode { int x, y; struct SnakeNode *next; } SnakeNode; typedef struct { SnakeNode *head; SnakeNode *tail; int length; int alive; } Snake; extern char map[HEIGHT][WIDTH]; void initGame(Snake *snake); void smoothMove(Snake *snake, int dx, int dy, int grow); int isHitSelf(Snake *snake, int x, int y); void spawnFood(void); void drawGame(Snake *snake); #endifextern char map[HEIGHT][WIDTH];这一行很关键。map定义在 snake.c 里main.c 要用它来绘制或读取就需要用extern声明。如果不写编译时链接阶段会报 undefined reference。5.2 编译命令与 Makefile命令行编译用 MinGW 的 gcc 最省事gcc -stdc11 -Wall main.c snake.c -o snake.exe-Wall打开所有常见警告课设里千万不要省。-o snake.exe指定输出文件名。如果在 Visual Studio 里写直接把三个文件拖进项目即可不需要手动指定链接。编译选项我会在报告的开发环境一节里列出来这也是一个容易被问到的细节。如果文件多了之后不想每次敲一长串命令用 Makefile 管理CC gcc CFLAGS -stdc11 -Wall snake.exe: main.o snake.o $(CC) $(CFLAGS) main.o snake.o -o snake.exe main.o: main.c snake.h $(CC) $(CFLAGS) -c main.c snake.o: snake.c snake.h $(CC) $(CFLAGS) -c snake.c clean: del main.o snake.o snake.exedel是 Windows 下的删除命令Linux/macOS 上要改成rm -f。main.o和snake.o的生成规则里都依赖snake.h因为两个源文件都include了它头文件一变依赖它的源文件必须重新编译。这是 Makefile 里最容易漏掉的一条依赖关系。5.3 题目里的 .doc 要装什么课程设计最后交的是一份 Word 文档除了源代码通常还要包含这几部分需求分析、总体设计、详细设计、测试截图、总结。写报告时不要只贴代码要把第 2 章和第 4 章里的几个决策点写进去——为什么用链表、边界怎么处理、自交判定怎么防误判。代码放附录就行。正文里可以放一个关键函数的流程图或者表格让老师不用打开编译器就能看懂程序结构。只要把“蛇的数据结构 碰撞检测策略”这两块讲明白报告的分数不会低。6. 答辩前必调的三个细节消闪烁、记分、自交检测验证6.1 system(cls) 会导致画面闪烁用光标定位替代清屏很多贪吃蛇代码用system(cls)清屏然后重新打印整个地图。这个函数会触发整个控制台缓冲区的清理和重绘视觉上就是画面明显闪烁演示时体验很差。替代方案是直接把光标定位到左上角然后覆盖式重绘不清屏。#include windows.h void gotoxy(int x, int y) { COORD pos { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); }每次绘制前调用gotoxy(0, 0)然后按行输出整个地图光标回到原处后旧内容自然被新内容覆盖。视觉上不存在清空再重画的间隔闪烁基本消失。还可以顺手隐藏光标CONSOLE_CURSOR_INFO cci { 25, FALSE }; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cci);FALSE表示不显示光标否则游戏运行中一直有个方块光标闪烁很干扰观感。这两段代码加在一起不到十行但对演示效果的提升非常明显。6.2 记分和蛇身长度实时显示分数可以在每次吃到食物后加 10并同步更新到控制台标题栏或者地图上方的信息区。用标题栏最省事几行代码就够char title[64]; snprintf(title, sizeof(title), 贪吃蛇 得分: %d 长度: %d, score, snake.length); SetConsoleTitleA(title);snprintf记得留出缓冲区大小防止字符串溢出。按分数递增给蛇加速的时候分数又是一个天然的变速依据。6.3 自检清单答辩前把这三个场景跑一遍最后动手验证三个关键场景确保代码经得起现场提问。第一蛇头向右时按下左键程序不应响应蛇继续向右不能出现瞬间暴毙。第二绕圈移动时蛇头经过尾巴即将离开的格子程序不应判定死亡。第三食物生成后连续吃满 20 个蛇身长度从初始值增加 20并且分数同步增加。这三个场景分别对应方向过滤、自交误判、食物计数。只要全通过答辩时老师大概率不会再追问核心逻辑剩下会问的问题无非是“内存什么时候释放”对答“退出时遍历链表释放所有节点或直接结束进程由操作系统回收”即可。本文还有配套的精品资源点击获取
返回列表