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_run |
| snake.c | 蛇的移动、增长、碰撞检测 | snake_move, snake_grow |
| food.c | 食物的生成、位置判断 | food_generate, food_eat |
| draw.c | 地图渲染、得分显示 | draw_board, draw_score |
| input.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++) { // i=0是蛇头自身 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_UP=0, DIR_DOWN=1,坐标设置是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都值得。