1. 从零写一个能跑的控制台贪吃蛇,到底难在哪
很多人学完 C 语言的struct、指针和malloc,再看链表那一章,感觉每个字都认识,合上书却写不出一个能动的程序。贪吃蛇就是打破这个僵局的好项目:它足够小,一个main.c就能装下;又足够完整,把链表的增删遍历、Win32 API 的光标定位和键盘读取全串了一遍。你不需要图形库,不需要额外依赖,只要一个 Windows 上的编译器(VS、MinGW、Dev-C++ 都行),就能做出一个真正能玩的东西。
这篇教程面向零基础读者,目标很明确:产出一个可玩的最小完整版本。蛇身用单向链表管理,每个节点存一个屏幕坐标;键盘输入用GetAsyncKeyState轮询,光标定位用SetConsoleCursorPosition。我会把链表节点结构体、增删遍历骨架、控制台初始化、按键读取、碰撞检测这些关键部分都写成可直接复制的代码,并给出编译运行和验证步骤。你跟着敲一遍,遇到报错就对照第 5 节的排查清单,基本都能解决。
需要说明的是,本文聚焦在 Windows 控制台环境,因为 Win32 API 是 Windows 平台特有的。如果你在别的系统上,思路可以借鉴,但函数名和头文件要换。下面所有代码都在 Windows 下用gcc和 VS 实测过,宽字符打印需要把控制台字体设成能显示中文或方块字的字体,否则会看到乱码,这一点后面会讲。
2. 动手前的准备:TaoToken 能帮你做什么
写代码的过程中,你大概率会遇到两类问题:一是某个 Win32 函数的参数记不清,二是链表操作写完后不确定逻辑对不对,想找个地方快速验证一下思路。这时候一个顺手的模型对话入口会省不少时间。我平时会把 TaoToken 当成一个查文档和理思路的辅助工具,它的模型对话入口可以直接问「SetConsoleCursorPosition的第二个参数是什么类型」这类具体问题,也能让它帮你逐行解释一段链表插入代码。
如果你打算长期写 C 或者做算法练习,可以考虑它的 Coding Plan,适合需要反复调试、持续对话的场景。接入方式也很简单,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。需要自己写脚本调用的话,去控制台拿 Key,入口在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言的调用示例。
这一节不是必须的,你完全可以只靠编译器和搜索引擎完成整个项目。但如果你希望有个地方能随时问「这段代码为什么崩了」,提前把入口准备好,后面排错会顺畅很多。下面进入正题,先把链表和 Win32 的骨架搭起来。
3. 可复制配置:链表节点、控制台初始化与按键读取
3.1 链表节点结构体与增删遍历骨架
蛇身的本质是一串坐标。蛇每走一步,就在头部加一个新坐标,尾部删掉一个旧坐标,这样视觉上就是整条蛇在移动。用单向链表最合适,因为头部插入和尾部删除都是 O(1) 或 O(n) 的简单操作。
先定义节点:
typedef struct SnakeNode { int x; // 列坐标 int y; // 行坐标 struct SnakeNode* next; // 指向下一个节点 } SnakeNode, *pSnakeNode;再定义一个游戏上下文结构体,把蛇头指针、食物指针、方向、状态、分数、速度都装进去:
enum DIRECTION { UP = 1, DOWN, LEFT, RIGHT }; enum GAME_STATUS { OK, KILL_BY_WALL, KILL_BY_SELF, END_NORMAL }; typedef struct Snake { pSnakeNode _pSnake; // 指向蛇头 pSnakeNode _pFood; // 指向食物节点 enum DIRECTION _dir; // 当前方向 enum GAME_STATUS _status;// 游戏状态 int _food_weight; // 一个食物的分数 int _score; // 总分数 int _sleep_time; // 每步休眠毫秒数,越小越快 } Snake, *pSnake;头插法的骨架,初始化蛇身时用得到:
pSnakeNode cur = (pSnakeNode)malloc(sizeof(SnakeNode)); cur->x = POS_X + 2 * i; cur->y = POS_Y; cur->next = ps->_pSnake; // 新节点指向原来的头 ps->_pSnake = cur; // 头指针移到新节点尾部删除的骨架,蛇没吃到食物时用得到:
pSnakeNode cur = ps->_pSnake; while (cur->next->next != NULL) { cur = cur->next; // 停在倒数第二个节点 } free(cur->next); // 释放最后一个节点 cur->next = NULL; // 截断链表遍历打印的骨架,每次移动后都要重新画蛇:
pSnakeNode cur = ps->_pSnake; while (cur) { SetPos(cur->x, cur->y); wprintf(L"%lc", BODY); cur = cur->next; }这三个骨架覆盖了链表在贪吃蛇里的全部用法。你只要理解「头插加节点、尾删减节点、遍历重画」这三件事,蛇的移动逻辑就通了。
3.2 控制台初始化:窗口大小、光标隐藏与坐标定位
Win32 API 里控制控制台,第一步是拿到标准输出设备的句柄。句柄你可以理解成一个「抓手」,拿到它才能对屏幕做操作。
#include <windows.h> #include <stdio.h> #include <locale.h> HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);设置窗口大小和标题,用system执行命令就行:
system("mode con cols=100 lines=33"); system("title 贪吃蛇");光标一直闪很影响观感,把它隐藏掉。注意流程是「先获取、再修改、再设置」,不能只改不设:
CONSOLE_CURSOR_INFO cursor_info = { 0 }; GetConsoleCursorInfo(hOutput, &cursor_info); cursor_info.bVisible = false; SetConsoleCursorInfo(hOutput, &cursor_info);坐标定位封装成一个函数,后面到处都要用:
void SetPos(short x, short y) { COORD pos = { x, y }; SetConsoleCursorPosition(hOutput, pos); }这里有个容易踩的坑:COORD的第一个成员是 X(列),第二个是 Y(行),很多人会写反。记住「先列后行」,SetPos(38, 14)表示第 38 列、第 14 行。
3.3 宽字符打印与本地化设置
地图边界用□,蛇身用●,食物用★,这些都是宽字符,占两个字节。打印宽字符要用wprintf,字面量前面加L:
#define WALL L'□' #define BODY L'●' #define FOOD L'★' wprintf(L"%lc", WALL);直接这么写,屏幕上可能显示不出来。需要在程序开头调用setlocale切换到本地环境:
setlocale(LC_ALL, "");这行的作用是让程序「入乡随俗」,适应操作系统的语言环境,这样宽字符才能正确输出。如果你不写这行,宽字符可能变成问号或乱码。
3.4 按键读取:GetAsyncKeyState 与宏封装
键盘输入不用scanf,因为scanf会阻塞,蛇就动不了了。用GetAsyncKeyState轮询,它不阻塞,随时能查某个键有没有被按过。
#define KEY_PRESS(VK) ((GetAsyncKeyState(VK) & 0x1) ? 1 : 0)方向键的虚拟键码是VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT,W 键是0x57,S 键是0x53,ESC 是VK_ESCAPE。在游戏主循环里这样用:
if (KEY_PRESS(VK_UP) && ps->_dir != DOWN) ps->_dir = UP; else if (KEY_PRESS(VK_DOWN) && ps->_dir != UP) ps->_dir = DOWN; else if (KEY_PRESS(VK_LEFT) && ps->_dir != RIGHT) ps->_dir = LEFT; else if (KEY_PRESS(VK_RIGHT) && ps->_dir != LEFT) ps->_dir = RIGHT; else if (KEY_PRESS(0x57)) { /* 加速 */ } else if (KEY_PRESS(0x53)) { /* 减速 */ } else if (KEY_PRESS(VK_ESCAPE)) ps->_status = END_NORMAL;注意方向判断里加了「不能反向」的条件,否则蛇会直接撞到自己。
4. 验证请求:编译、运行与碰撞检测
4.1 编译命令与运行结果
把代码保存成snake.c,用 gcc 编译:
gcc snake.c -o snake.exe -lwinmm如果你用的是 VS 开发者命令提示符,直接cl snake.c也行。运行:
snake.exe正常的话,你会先看到欢迎界面,按任意键后进入游戏,屏幕上出现一圈□边框,中间一条●组成的蛇,还有一个★食物。按方向键蛇会移动,吃到食物变长、加分,撞墙或撞自己游戏结束。
4.2 碰撞检测的验证步骤
碰撞检测分两种:撞墙和撞自己。撞墙判断蛇头坐标是否等于边界坐标:
void KillByWall(pSnake ps) { if (ps->_pSnake->x == 0 || ps->_pSnake->x == 56 || ps->_pSnake->y == 0 || ps->_pSnake->y == 28) { ps->_status = KILL_BY_WALL; } }撞自己则遍历蛇头后面的所有节点,看有没有坐标重合:
void KillBySelf(pSnake ps) { pSnakeNode cur = ps->_pSnake->next; while (cur) { if (cur->x == ps->_pSnake->x && cur->y == ps->_pSnake->y) { ps->_status = KILL_BY_SELF; break; } cur = cur->next; } }验证方法:故意让蛇往墙上撞,看是否提示「撞到墙上,游戏结束」;再让蛇转个圈咬到自己,看是否提示「撞到了自己」。两个都触发,说明碰撞检测正常。
4.3 食物生成与吃到食物的验证
食物坐标有两个约束:X 必须是 2 的倍数,且不能和蛇身重合。X 是 2 的倍数是因为宽字符占两列,蛇头每次移动 X 变化 2,如果食物 X 是奇数,蛇头永远到不了。
again: do { x = rand() % 53 + 2; y = rand() % 28 + 1; } while (x % 2 != 0); pSnakeNode cur = ps->_pSnake; while (cur) { if (x == cur->x && y == cur->y) goto again; cur = cur->next; }验证方法:连续吃几个食物,观察分数是否按_food_weight增加,蛇身是否变长,新食物是否出现在空白位置。如果食物出现在蛇身上,说明去重逻辑有问题。
5. 本篇常见错排查
5.1 宽字符显示成乱码或问号
最常见的原因是没调用setlocale(LC_ALL, ""),或者控制台字体不支持。先确认代码里有这行,且放在wprintf之前。如果还不行,右键控制台标题栏,选「属性」,把字体改成「点阵字体」或「Consolas」以外的支持中文的字体,比如「新宋体」。另外,源文件保存时要选 UTF-8 或 GBK 编码,和编译器设置一致,否则宽字符字面量本身就可能出错。
5.2 蛇移动时闪烁严重
闪烁是因为每次重画整条蛇。优化方法是只重画变化的部分:新蛇头画●,旧尾巴画空格。NoFood函数里已经这么做了:
SetPos(cur->next->x, cur->next->y); printf(" ");如果你还是觉得闪,可以试试双缓冲,但控制台程序双缓冲比较麻烦,最小版本不用管,能玩就行。
5.3 蛇撞墙判断不生效
检查边界坐标是否和你实际画的地图一致。本文用的是 30 行、58 列的地图,所以 X 边界是 0 和 56,Y 边界是 0 和 28。如果你改了mode con的列数或画墙的循环次数,边界值也要跟着改。另外注意SetPos的参数顺序是「列、行」,别写反。
5.4 内存泄漏或程序崩溃
每次malloc都要有对应的free。游戏结束时必须遍历链表释放所有节点:
pSnakeNode cur = ps->_pSnake; while (cur) { pSnakeNode del = cur; cur = cur->next; free(del); }如果崩溃发生在SnakeMove里,检查pNextNode是否为 NULL,以及cur->next->next访问时cur->next是否为空。蛇长度为 1 时,尾部删除逻辑要特别小心,最小版本建议初始长度设为 4,避免边界情况。
5.5 按键没反应或方向错乱
GetAsyncKeyState的返回值要用& 0x1判断「是否被按过」,不是判断「是否按下」。如果你写成if (GetAsyncKeyState(VK_UP)),会因为高位状态导致误判。另外,方向键的虚拟键码别用错,VK_UP是上,VK_DOWN是下,和直觉一致。如果按键完全没反应,检查游戏主循环里有没有调用Sleep,Sleep时间太长会导致轮询间隔过大,按键被漏掉。
6. 继续往下走:把最小版本变成你自己的项目
到这里,一个能跑、能玩、能计分的控制台贪吃蛇就完成了。你手里应该有一个snake.c,编译后能正常游戏,撞墙和撞自己都有提示,食物生成也符合预期。接下来可以做的优化有很多:给蛇头换个不同的宽字符,让视觉上更容易分辨;加一个「穿墙」模式,撞墙后从对面出来;或者把食物分成不同分值,吃一个高分食物加更多分。这些改动都不难,核心的链表和 Win32 骨架不用动。
如果你在调试过程中遇到想不明白的报错,可以把错误信息贴到模型对话里问,入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,让它帮你定位问题。需要自己写脚本批量测试或者做自动化验证的话,去控制台拿 Key,地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 管理。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的调用说明。
最后提醒一句:链表操作里最容易出错的是指针的指向和释放顺序,写的时候多画图,把每个节点的next指向标清楚,比盯着代码看有效得多。贪吃蛇这个项目不大,但把链表和 Win32 这两块啃下来,后面再写更复杂的控制台程序,心里就有底了。