1. 从黑框到可玩:贪吃蛇小项目到底在练什么
很多人学完 C 语言的for、while、结构体、指针之后,会陷入一种“我好像会了,又好像什么都不会”的状态。原因很简单:语法是散的,项目是整的。贪吃蛇这个项目之所以经典,是因为它把三块最核心的能力串成了一条线——Win32 API 控制台绘图、链表管理蛇身、键盘输入响应。你写完之后,收获的不是一个游戏,而是一套“用 C 语言操作真实系统”的完整手感。
先说清楚它是什么。这是一个运行在 Windows 控制台(就是那个黑框)里的贪吃蛇,用 C 语言从零写,不依赖任何图形库,只调用 Windows 自带的 API。蛇身用链表存,每吃一个食物就头插一个新节点;地图和蛇用宽字符█、◎、★画出来;键盘用GetAsyncKeyState实时读取方向键。适合谁?适合刚学完 C 语言基础语法、想找一个能跑起来、能改、能炫的小项目练手的人。也适合想复习链表和指针的人——因为蛇的移动、增长、销毁,全是链表操作。
我试过把这个项目拆成三个文件:Snake.h放声明和结构体,Snake.c放实现,test.c放入口。这样改起来不痛苦,编译也不会一团乱。下面我会把每一步都写成可复制的代码,你跟着敲,最后能编译出一个真正能玩的版本。整个过程不需要额外装什么,只要你有 Windows 和一个能编译 C 的环境(VS、Dev-C++、MinGW 都行)。
核心检索词先摆出来:C语言贪吃蛇 Win32 API 控制台链表实现。你搜到的很多版本要么只给代码不讲原理,要么用数组代替链表。这篇会把链表节点结构体、控制台光标与颜色配置、编译运行与按键测试的验证步骤全部交付,让你独立完成可玩版本。
在动手之前,先理解一个关键点:控制台不是画布,它是一格一格的字符网格。你看到的“蛇在动”,本质是不断擦掉旧位置、在新位置打印字符。所以光标定位函数是整个项目的命脉。理解了这一点,后面的SetPos、DrawMap、SnakeMove就都顺了。
2. TaoToken 前置:给调试和代码补全配一个稳定入口
写这种小项目,最烦的不是逻辑,而是环境。比如你敲到一半,某个 API 的参数记不清了,或者想快速验证一段链表遍历逻辑对不对。这时候如果有一个能直接对话、能补全、能解释报错的入口,效率会高很多。TaoToken 就是干这个的——它提供统一的 API 入口,让你在写代码时随时调用模型能力做辅助。
先说清楚它是什么、能做什么。TaoToken 是一个大模型 API 聚合服务,你可以把它理解成一个“统一的模型调用网关”。你不需要分别去注册一堆平台、记一堆 Key,而是用一套 Base URL 和 Key,就能调用不同模型。对于写 C 语言小项目的人来说,它的价值在于:遇到GetAsyncKeyState返回值看不懂、链表头插写反了、wprintf打印乱码,可以直接把代码贴进去问,比翻文档快。
适合谁?适合正在做项目、需要边写边查边验证的人。尤其是你写贪吃蛇这种涉及 Win32 API 和指针的项目,报错信息往往很隐晦,比如local proxy failed、reading choices这类,有人帮你翻译一下能省很多时间。
前置准备很简单,三步:
第一步,拿到 API Key。访问 API Keys 页面(https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite),登录后创建一个 Key,复制保存。注意这个 Key 只显示一次,丢了就重新建。
第二步,确认 Base URL。TaoToken 的 API 地址是https://taotoken.net/api,注意这个地址不加 UTM 参数,直接用于代码里的base_url配置。
第三步,选一个模型 ID。比如你想用 Claude 系列做代码解释,或者用其他模型做补全,在模型对话页面(https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite)能看到可用模型列表。记下你要用的 Model ID,后面配置里要填。
如果你打算长期写代码、跑 Agent 类任务,可以了解一下 Coding Plan(https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite),它更适合高频调用场景。但就贪吃蛇这个项目而言,用按量调用就够了。
这里要强调一点:TaoToken 不是让你替代编译器,也不是让你把整个项目丢给模型生成。它的定位是“辅助”——你写主逻辑,遇到卡点用它查。比如链表节点的next指针什么时候置空、SetConsoleCursorPosition的COORD为什么 X 要乘 2,这些细节问一下比瞎试快。
配置的时候有个坑要注意:Base URL 一定要写全,包括https://,不要只写域名。另外 Key 不要硬编码在提交到 Git 的代码里,本地测试可以用环境变量或者单独的配置文件。下面第三节我会给出一份可复制的 JSON 配置片段,你直接改 Key 和 Model ID 就能用。
3. 可复制配置:JSON、TOML 与 settings 片段
这一节给你三份可直接复制的配置,分别对应不同的使用方式。你不需要全用,选一个顺手的就行。重点是路径和字段名要和原文一致,不然会报401或者local proxy failed。
先给一份通用的 JSON 配置,适合大多数支持 OpenAI 兼容接口的客户端:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key粘贴在这里", "model": "claude-3-5-sonnet", "timeout": 60, "max_tokens": 4096 }这份配置里,base_url是固定的,不要加 UTM 参数;api_key换成你在 API Keys 页面创建的那个;model换成你在模型对话页面看到的 Model ID。timeout和max_tokens按需调整,写代码解释一般 60 秒够用。
如果你用的是支持 TOML 的工具,比如某些 CLI 客户端,可以用这份:
[provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-你的Key粘贴在这里" [model] id = "claude-3-5-sonnet" max_tokens = 4096 temperature = 0.3注意 TOML 里字符串要用双引号,temperature写代码辅助建议低一点,0.2 到 0.4 之间,输出更稳定。
如果你用的是 VS Code 的 settings.json 配置某个 AI 插件,可以这样写:
{ "aiAssistant.provider": "openai-compatible", "aiAssistant.baseUrl": "https://taotoken.net/api", "aiAssistant.apiKey": "sk-你的Key粘贴在这里", "aiAssistant.model": "claude-3-5-sonnet", "aiAssistant.enableCodeActions": true }这里字段名可能因插件不同而略有差异,核心是三件套:Base URL + Key + Model ID。只要这三个对了,基本就能通。
配置完之后,怎么验证?不要急着写贪吃蛇,先发一个最小请求。用 curl 测试:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "claude-3-5-sonnet", "messages": [{"role": "user", "content": "用一句话解释C语言链表头插法"}] }'如果返回里有choices字段和正常文本,说明配置通了。如果返回401,检查 Key 有没有复制错、有没有多余空格。如果返回local proxy failed,检查你的网络环境是否直连,不要挂任何代理工具。如果返回reading choices相关错误,多半是返回体格式和你客户端预期不一致,换一个兼容模式试试。
还有一个常见问题是 Model ID 写错。比如你写claude-3.5但实际 ID 是claude-3-5-sonnet,就会报模型不存在。去模型对话页面确认一下准确的 ID。
配置这件事,一次弄好,后面写代码就顺了。下面进入正题,开始写贪吃蛇。
4. 从零实现:链表节点、控制台绘图与键盘响应
这一节是重头戏,我会把完整源码拆成可理解的块。你先建三个文件:Snake.h、Snake.c、test.c。
4.1 头文件与链表节点结构体定义
先写Snake.h,把所有声明、宏、结构体放进去:
#ifndef SNAKE_H #define SNAKE_H #include <stdio.h> #include <locale.h> #include <Windows.h> #include <stdbool.h> #include <stdlib.h> #include <time.h> #define KEYPRESS(vk) ((GetAsyncKeyState(vk) & 1) ? 1 : 0) typedef struct SnakeNode { int x; int y; struct SnakeNode* next; } SnakeNode, *pSnakeNode; enum DIRECTION { DIR_UP = 1, DIR_DOWN, DIR_LEFT, DIR_RIGHT }; enum GameState { SnakeAlive, SnakeHitWall, SnakeHitSelf, SnakeExited }; typedef struct Snake { pSnakeNode pSnake; pSnakeNode pFood; enum DIRECTION dir; enum GameState state; int foodScore; int score; int sleepTime; } Snake, *pSnake; void InitGame(pSnake ps); void SetPos(short x, short y); void PrintWelcomeScreen(); void DrawMap(); void InitSnake(pSnake ps); void InitFood(pSnake ps); void RunGame(pSnake ps); void SnakeMove(pSnake ps); int NextIsFood(pSnakeNode pn, pSnake ps); void EatFood(pSnakeNode pn, pSnake ps); void NoFood(pSnakeNode pn, pSnake ps); void CheckWallCollision(pSnake ps); void CheckSelfCollision(pSnake ps); void EndGame(pSnake ps); #endif这里的关键是SnakeNode结构体,它存x、y坐标和next指针。蛇身就是一条单向链表,蛇头是pSnake指向的节点。KEYPRESS宏把GetAsyncKeyState的返回值最低位取出来,判断按键是否被按过。
4.2 控制台光标定位与隐藏
在Snake.c里先实现SetPos:
void SetPos(short x, short y) { HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); COORD pos = { x, y }; SetConsoleCursorPosition(hOutput, pos); }这个函数是整个绘图的基础。COORD是 Win32 定义的结构体,存 X 和 Y。注意控制台里 X 轴一个宽字符占两格,所以后面蛇移动时 X 每次加减 2。
隐藏光标:
void HideCursor() { HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cursorInfo; GetConsoleCursorInfo(hOutput, &cursorInfo); cursorInfo.bVisible = FALSE; SetConsoleCursorInfo(hOutput, &cursorInfo); }4.3 绘制地图与欢迎界面
地图用宽字符█画边框。因为宽字符占两格,上边界循环 50 次,左右边界每行打两个:
void DrawMap() { int i = 0; for (i = 0; i < 50; i++) { wprintf(L"%1c", L'█'); } SetPos(0, 29); for (i = 0; i < 50; i++) { wprintf(L"%1c", L'█'); } for (i = 1; i <= 28; i++) { SetPos(0, i); wprintf(L"%1c", L'█'); SetPos(1, i); wprintf(L"%1c", L'█'); } for (i = 1; i <= 28; i++) { SetPos(48, i); wprintf(L"%1c", L'█'); SetPos(49, i); wprintf(L"%1c", L'█'); } }欢迎界面:
void PrintWelcomeScreen() { SetPos(53, 14); wprintf(L"欢迎来到贪吃蛇\n"); SetPos(51, 20); system("pause"); system("cls"); SetPos(53, 14); wprintf(L"规则简单介绍:\n"); SetPos(46, 15); wprintf(L"(1)按 ↑ ↓ ← → 控制移动方向\n"); SetPos(46, 16); wprintf(L"(2)按 E 加速 按 Q 减速\n"); SetPos(46, 17); wprintf(L"(3)加速状态得分更高\n"); SetPos(51, 20); system("pause"); system("cls"); }4.4 初始化蛇与食物
蛇初始化用头插法,开局 5 个节点,向右走:
void InitSnake(pSnake ps) { int i = 0; pSnakeNode pcur = NULL; for (i = 0; i < 5; i++) { pcur = (pSnakeNode)malloc(sizeof(SnakeNode)); if (pcur == NULL) { perror("malloc fail"); return; } pcur->next = NULL; pcur->x = 20 + 2 * i; pcur->y = 15; if (ps->pSnake == NULL) { ps->pSnake = pcur; } else { pcur->next = ps->pSnake; ps->pSnake = pcur; } } pcur = ps->pSnake; while (pcur) { SetPos(pcur->x, pcur->y); wprintf(L"%1c", L'◎'); pcur = pcur->next; } ps->dir = DIR_RIGHT; ps->score = 0; ps->sleepTime = 200; ps->state = SnakeAlive; ps->foodScore = 1; }食物初始化要随机且不落在蛇身上,X 必须是偶数:
void InitFood(pSnake ps) { int x = 0, y = 0; again: do { x = rand() % 46 + 2; y = rand() % 27 + 1; } while (x % 2 != 0); pSnakeNode pcur = ps->pSnake; while (pcur) { if (x == pcur->x && y == pcur->y) { goto again; } pcur = pcur->next; } pSnakeNode pFood = (pSnakeNode)malloc(sizeof(SnakeNode)); if (pFood == NULL) { perror("food malloc fail"); return; } pFood->x = x; pFood->y = y; pFood->next = NULL; SetPos(x, y); wprintf(L"%1c", L'★'); ps->pFood = pFood; }4.5 蛇的移动、吃食物与碰撞检测
移动逻辑:先创建一个新节点,根据方向算出下一个坐标,然后判断是不是食物。
void SnakeMove(pSnake ps) { pSnakeNode pNext = (pSnakeNode)malloc(sizeof(SnakeNode)); if (pNext == NULL) { perror("move malloc fail"); return; } switch (ps->dir) { case DIR_UP: pNext->x = ps->pSnake->x; pNext->y = ps->pSnake->y - 1; break; case DIR_DOWN: pNext->x = ps->pSnake->x; pNext->y = ps->pSnake->y + 1; break; case DIR_LEFT: pNext->x = ps->pSnake->x - 2; pNext->y = ps->pSnake->y; break; case DIR_RIGHT: pNext->x = ps->pSnake->x + 2; pNext->y = ps->pSnake->y; break; } if (NextIsFood(pNext, ps)) { EatFood(pNext, ps); } else { NoFood(pNext, ps); } CheckWallCollision(ps); CheckSelfCollision(ps); }吃食物就是头插,然后重新生成食物:
void EatFood(pSnakeNode pn, pSnake ps) { ps->pFood->next = ps->pSnake; ps->pSnake = ps->pFood; free(pn); pn = NULL; pSnakeNode pcur = ps->pSnake; while (pcur) { SetPos(pcur->x, pcur->y); wprintf(L"%1c", L'◎'); pcur = pcur->next; } ps->score += ps->foodScore; InitFood(ps); }不吃食物就头插新节点,删尾节点,并用空格覆盖:
void NoFood(pSnakeNode pn, pSnake ps) { pn->next = ps->pSnake; ps->pSnake = pn; pSnakeNode pcur = ps->pSnake; while (pcur->next->next != NULL) { SetPos(pcur->x, pcur->y); wprintf(L"%1c", L'◎'); pcur = pcur->next; } SetPos(pcur->next->x, pcur->next->y); wprintf(L" "); free(pcur->next); pcur->next = NULL; }碰撞检测:
void CheckWallCollision(pSnake ps) { if (ps->pSnake->x == 0 || ps->pSnake->x == 48 || ps->pSnake->y == 0 || ps->pSnake->y == 29) { ps->state = SnakeHitWall; } } void CheckSelfCollision(pSnake ps) { pSnakeNode pcur = ps->pSnake->next; while (pcur) { if (pcur->x == ps->pSnake->x && pcur->y == ps->pSnake->y) { ps->state = SnakeHitSelf; break; } pcur = pcur->next; } }4.6 主循环与键盘响应
void RunGame(pSnake ps) { do { SetPos(72, 12); wprintf(L"总分:%2d\n", ps->score); SetPos(72, 13); wprintf(L"当前食物分数:%d\n", ps->foodScore); if (KEYPRESS(VK_UP) && ps->dir != DIR_DOWN) { ps->dir = DIR_UP; } else if (KEYPRESS(VK_DOWN) && ps->dir != DIR_UP) { ps->dir = DIR_DOWN; } else if (KEYPRESS(VK_LEFT) && ps->dir != DIR_RIGHT) { ps->dir = DIR_LEFT; } else if (KEYPRESS(VK_RIGHT) && ps->dir != DIR_LEFT) { ps->dir = DIR_RIGHT; } else if (KEYPRESS(VK_ESCAPE)) { ps->state = SnakeExited; } else if (KEYPRESS('E')) { if (ps->sleepTime > 50) { ps->sleepTime -= 30; ps->foodScore += 1; } } else if (KEYPRESS('Q')) { if (ps->score > 0 && ps->foodScore > 0) { ps->sleepTime += 30; ps->foodScore -= 1; } } SnakeMove(ps); Sleep(ps->sleepTime); } while (ps->state == SnakeAlive); }4.7 入口与游戏善后
test.c:
#include "Snake.h" void SnakeTest() { char ch = 0; do { system("cls"); Snake snake = { 0 }; InitGame(&snake); RunGame(&snake); EndGame(&snake); SetPos(66, 10); printf("是否重新开始:是(Y)/否(N)->"); while (1) { if (KEYPRESS('Y')) { ch = 'Y'; break; } else if (KEYPRESS('N')) { ch = 'N'; break; } } } while (ch == 'Y'); } int main() { setlocale(LC_ALL, ""); srand((unsigned int)time(NULL)); SnakeTest(); return 0; }InitGame和EndGame在Snake.c里补全:
void InitGame(pSnake ps) { system("mode con cols=100 lines=30"); system("title 贪吃蛇"); HideCursor(); PrintWelcomeScreen(); DrawMap(); InitSnake(ps); InitFood(ps); } void EndGame(pSnake ps) { SetPos(72, 9); switch (ps->state) { case SnakeExited: printf("游戏已退出\n"); break; case SnakeHitWall: printf("撞墙了,游戏结束\n"); break; case SnakeHitSelf: printf("撞到自己了,游戏结束\n"); break; } pSnakeNode pcur = ps->pSnake; while (pcur) { pSnakeNode del = pcur; pcur = pcur->next; free(del); } }5. 编译运行与按键测试:验证每一步是否真的通了
代码写完了,接下来是验证。这一步很多人会跳过,结果运行起来一堆问题。我按顺序给你验证步骤。
第一步,编译。如果你用 gcc(MinGW),命令是:
gcc test.c Snake.c -o snake.exe -lwinmm注意-lwinmm不是必须的,但有些环境需要。如果你用 VS,直接新建空项目,把三个文件加进去,编译即可。如果报wprintf未定义,检查有没有#include <locale.h>和setlocale(LC_ALL, "")。
第二步,运行。双击snake.exe,应该看到欢迎界面,按任意键继续,然后出现地图和蛇。如果地图边框是乱码,说明本地化没设置好,检查setlocale那行。
第三步,按键测试。按方向键,蛇应该转向。按 E 加速,蛇变快,分数增加。按 Q 减速。按 ESC 退出。如果按键没反应,检查KEYPRESS宏有没有写对,GetAsyncKeyState的返回值判断是不是用了& 1。
第四步,吃食物测试。控制蛇去吃★,吃到后蛇变长,分数增加,新食物出现。如果蛇吃到食物后没变长,检查EatFood里的头插逻辑。
第五步,碰撞测试。故意撞墙,应该提示“撞墙了”。故意让蛇头碰到自己身体,应该提示“撞到自己了”。如果没提示,检查CheckWallCollision和CheckSelfCollision的坐标判断。
常见报错对照:
| 报错/现象 | 原因 | 解决 |
|---|---|---|
401 Unauthorized | Key 错误或过期 | 重新创建 Key,检查有没有空格 |
local proxy failed | 网络环境有代理干扰 | 关闭代理工具,直连 |
reading choices相关 | 返回格式不匹配 | 检查客户端兼容模式 |
| 地图乱码 | 本地化未设置 | 加setlocale(LC_ALL, "") |
| 蛇移动但旧位置不擦除 | NoFood没覆盖空格 | 检查wprintf(L" ")那行 |
| 食物生成在蛇身上 | 随机范围没排除蛇身 | 检查InitFood的goto again |
| 按键无响应 | 宏写错或句柄问题 | 检查KEYPRESS定义 |
编译报COORD未定义 | 没包含Windows.h | 检查头文件 |
如果你在配置 TaoToken 时遇到OAuth相关提示,说明你用的客户端走了 OAuth 流程而不是 API Key,换成 API Key 模式即可。如果出现auth.json相关错误,检查你的配置文件路径是否正确,字段名是不是api_key而不是apikey。
验证通过后,你可以试着改一些参数:把sleepTime初始值改成 100,蛇会更快;把地图大小改大,需要同步改边界判断;把蛇初始长度改成 10,看看链表头插有没有问题。这些改动能帮你真正理解代码。
6. 继续深入:把项目变成你自己的
到这里,一个可玩的贪吃蛇已经跑起来了。但我想说的是,这个项目的价值不在于“完成”,而在于“可改”。你可以基于它做很多延伸。
比如,把链表换成双向链表,这样删除尾节点不用遍历。或者加一个“穿墙”模式,撞墙后从另一边出来。再或者加一个排行榜,把最高分写到文件里。这些改动都不难,但能让你对 C 语言和 Win32 API 的理解更深一层。
如果你在改的过程中遇到问题,比如链表操作写反了、API 参数记不清了,可以用 TaoToken 的模型对话快速问一下。它的接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有完整的接口说明。需要长期写代码的话,Coding Plan 更适合高频场景。
最后给你一个实用技巧:写这种控制台项目,调试时不要只靠printf。可以用SetPos把中间变量打到屏幕角落,比如蛇头坐标、当前方向、链表长度。这样运行起来一眼就能看出哪里不对。我踩过的坑是,一开始没隐藏光标,光标在地图上乱闪,后来加了HideCursor才清爽。
代码写完之后,建议你手动敲一遍,不要直接复制。敲的过程中你会遇到各种小错误,比如分号漏了、括号不匹配、wprintf写成printf。这些错误才是真正让你进步的地方。等你把整个项目跑通,再回头看链表和 Win32 API,会发现它们不再抽象,而是你亲手用过的工具。