1. 为什么选择用C手写一个贪吃蛇1.1 这个项目的定位与适合人群贪吃蛇这个游戏几乎每个学编程的人都见过但真正自己从零用C写一个能跑、手感还不错的版本其实比想象中要讲究。它不是那种复制粘贴就能交差的作业题而是一个能把语言基础、数据结构、输入处理、渲染刷新、状态管理全部串起来的综合练习。我前后写过好几个版本从最早的黑框控制台版到后来用图形库做的带颜色版本每一次重写都会发现新的坑。这个项目适合三类人第一类是刚学完C基础语法想找个东西练手但不知道从哪下手的同学第二类是写过一些算法题但对一个完整程序怎么组织没有概念的开发者第三类是想给自己或身边人做个小玩具顺便复习一下底层逻辑的老手。它解决的问题很具体——把零散的语法知识变成一个能交互、有反馈、能玩起来的完整程序。核心关键词就是C、贪吃蛇、控制台游戏、游戏循环、蛇身数据结构、碰撞检测、输入处理。这些词看着简单但每一个背后都有值得展开的细节。下面我会按照实际开发的思路从整体设计一路讲到排查问题的经验尽量把每个为什么这么写都讲清楚。1.2 为什么不用现成引擎而是手写很多人第一反应是用Unity或者某个游戏引擎不香吗。香但那是另一回事。用引擎做贪吃蛇你学到的是引擎的API手写贪吃蛇你学到的是游戏到底怎么运转的。贪吃蛇的逻辑足够简单简单到你可以把每一行代码都吃透同时又足够完整完整到它包含了几乎所有游戏都有的核心要素初始化、输入、更新、渲染、结束判定。我个人的判断标准是如果一个项目用引擎做你花在学引擎上的时间超过写逻辑的时间那就不如手写。贪吃蛇正好卡在这个临界点上——手写完全可控而且能让你真正理解游戏循环这个概念。这个概念在后面你写任何交互式程序时都会用到不管是终端工具还是图形界面。还有一点手写版本没有依赖拿到任何一台有编译器的机器上都能跑。这种自包含的特性在分享代码、做教学演示的时候特别省心。你不需要跟别人解释怎么装环境、怎么配路径一个命令编译完就能玩。2. 整体架构设计与核心思路拆解2.1 游戏循环整个程序的心脏任何实时交互程序的核心都是一个循环游戏里管它叫游戏循环Game Loop。它的结构非常朴素处理输入、更新状态、渲染画面然后重复。听起来简单但新手最容易犯的错就是把这个循环写成一坨输入、逻辑、绘制全混在一起改一个地方就崩一片。我习惯把它拆成三个清晰的阶段。第一阶段是输入采集把这一帧用户按了什么键读进来存成一个待处理的动作。第二阶段是状态更新根据当前蛇的位置、方向、食物位置算出下一帧蛇应该在哪、有没有吃到、有没有撞到。第三阶段是渲染把更新后的状态画到屏幕上。三个阶段各管各的互不干扰。为什么要这么分因为这样你调试的时候能精确定位问题。比如蛇不动了你先看输入阶段有没有读到按键再看更新阶段方向有没有变最后看渲染阶段画的位置对不对。如果全混在一起你只能靠猜。这个拆分思路不只适用于贪吃蛇写任何有状态的交互程序都通用。2.2 蛇身用什么数据结构这是整个项目里最值得琢磨的一个选择。蛇身本质上是一串有序的坐标点头在最前面尾在最后面。最直觉的做法是用数组或者std::vector每次移动时把所有元素往后挪一位。但这样有个问题移动是每帧都发生的操作如果蛇很长每帧都要挪动整个数组效率不高而且代码写起来也啰嗦。更好的选择是双端队列std::deque。蛇的移动本质上就是头部加一节尾部删一节没吃到食物时或者头部加一节尾部不动吃到食物时。这两个操作正好对应deque的push_front和pop_back都是O(1)的复杂度代码也极其直观。我实测下来用deque写出来的移动逻辑只有几行而且不容易出错。// 蛇身用deque存储头部在front std::dequePoint snake; // 移动头部加新位置 snake.push_front(newHead); // 没吃到食物就删尾部 if (!ateFood) { snake.pop_back(); }这里有个细节要注意判断有没有撞到自己的时候不能简单地遍历整个deque去比对因为头部刚加进去的新位置如果和身体某节重合那就是撞了。但如果你先push_front再检查头部自己会和自己重合。正确的顺序是先算出新头部位置检查这个位置是否已经在蛇身里排除掉即将被删掉的尾部确认安全后再push_front。这个顺序问题我踩过坑后面排查部分会细说。2.3 坐标系与方向表示控制台游戏的坐标系和数学课上的不太一样。控制台里行是纵轴从上往下增大列是横轴从左往右增大。所以向上其实是行号减一向下是行号加一。这个一定要在脑子里理清楚不然方向逻辑会写反。方向我用一个枚举表示四个值上、下、左、右。每个方向对应一个坐标偏移量。比如向上是{-1, 0}向右是{0, 1}。这样移动的时候只需要把当前头部坐标加上偏移量就得到新头部坐标非常干净。enum Direction { UP, DOWN, LEFT, RIGHT }; // 方向对应的偏移量 Point offset[] {{-1,0}, {1,0}, {0,-1}, {0,1}};这里有个经典的反向问题蛇在向右走的时候你不能让它直接掉头向左否则它会立刻撞到自己。所以每次收到方向输入时要判断新方向是不是当前方向的反方向如果是就忽略。这个判断很简单但漏掉的话游戏体验会很怪——玩家按一下反方向键蛇就自杀了。2.4 渲染方式的选择控制台渲染有几种做法。最土的是每次清屏重画用system(cls)或者system(clear)。这个做法简单但有个致命问题闪烁。因为清屏和重画之间有个时间差屏幕会一闪一闪的玩久了眼睛疼。稍微好一点的做法是用光标定位只把变化的部分重画不清整个屏幕。在Windows下可以用SetConsoleCursorPosition在类Unix系统下可以用ANSI转义序列\033[y;xH。这样画面稳定得多而且性能也好。我现在的版本都是用光标定位把蛇头、蛇身、食物分别画到对应位置背景不动。再讲究一点可以用双缓冲先在内存里把整帧画面拼好再一次性输出。不过对于贪吃蛇这种简单游戏光标定位已经足够了双缓冲有点杀鸡用牛刀。选择方案的原则是够用就好不要为了炫技引入不必要的复杂度。3. 核心细节解析与实操要点3.1 食物生成别让食物长在蛇身上食物生成看起来简单——随机一个坐标就行。但这里有个隐蔽的坑随机出来的位置可能正好在蛇身上。如果不管玩家就会看到食物和蛇身重叠吃不到或者视觉上很怪。正确的做法是生成一个候选位置检查它是否在蛇身里如果在就重新生成直到找到一个空位。这个循环在蛇很短的时候几乎一次就成功蛇很长的时候可能要试几次但总体开销可以接受。如果蛇长到几乎占满整个地图那说明玩家快赢了这时候可以特殊处理比如直接判定胜利。Point generateFood(const std::dequePoint snake, int rows, int cols) { Point food; do { food.x rand() % rows; food.y rand() % cols; } while (std::find(snake.begin(), snake.end(), food) ! snake.end()); return food; }注意随机数种子一定要在程序开始时设置一次用std::srand(std::time(nullptr))。如果不设种子每次运行食物位置都一样玩两次就腻了。但也别在循环里反复设种子那样随机数会退化成固定值。3.2 输入处理非阻塞读取是关键控制台默认的输入是阻塞的——程序会停在那里等你按键。但游戏不能停蛇要一直动。所以必须用非阻塞输入也就是有键就读没键就继续。在Windows下可以用_kbhit()判断有没有按键用_getch()读取按键这两个函数都不需要回车确认非常适合游戏。在类Unix系统下需要把终端设成非规范模式用termios结构体关掉行缓冲和回显然后用read读取。这部分跨平台差异比较大如果只针对一个平台选对应的方案就行如果要跨平台可以用条件编译把两套实现分开。#ifdef _WIN32 #include conio.h if (_kbhit()) { int key _getch(); // 处理按键 } #else // 使用termios设置非规范模式 // 然后用read读取 #endif这里有个细节方向键在Windows下_getch()会返回两次第一次是0或224第二次才是具体的方向码。所以读到0或224时要再读一次。这个坑很多人第一次写都会遇到按方向键没反应就是因为只读了第一次的返回值。3.3 碰撞检测边界和自身都要查碰撞检测分两种撞墙和撞自己。撞墙很简单新头部坐标超出地图范围就是撞了。撞自己稍微麻烦一点要判断新头部坐标是否和蛇身某节重合。前面提过顺序问题这里展开说。假设蛇是[头, 身1, 身2, 尾]现在要往右移动一格。新头部位置如果正好等于尾的位置这算不算撞答案是不算因为这一帧尾部会被删掉新头部占据的是尾部腾出来的位置。所以检查的时候要排除掉尾部。// 先算新头部 Point newHead {snake.front().x offset[dir].x, snake.front().y offset[dir].y}; // 检查是否吃到食物 bool ateFood (newHead food); // 检查撞墙 if (newHead.x 0 || newHead.x rows || newHead.y 0 || newHead.y cols) { gameOver true; } // 检查撞自己注意排除尾部如果没吃到食物 auto endIt ateFood ? snake.end() : std::prev(snake.end()); if (std::find(snake.begin(), endIt, newHead) ! endIt) { gameOver true; }这个ateFood的判断顺序很重要。如果先判断撞自己再判断吃食物那吃到食物时尾部不删检查范围就要包含尾部逻辑会绕。先判断吃食物再根据结果决定检查范围思路最清晰。3.4 速度控制别让蛇跑太快或太慢游戏速度直接决定手感。太快了反应不过来太慢了无聊。控制台游戏没法像图形游戏那样精确控制帧率但可以用sleep或者std::this_thread::sleep_for来控制每帧的间隔。我的经验值是初始每帧150毫秒左右随着分数增加逐渐加快最快到60毫秒左右就差不多了。再快人的反应跟不上游戏就变成拼运气了。加速的节奏也要注意别一下子从150跳到60那样难度曲线太陡。可以每吃5个食物减10毫秒平滑过渡。int baseDelay 150; // 毫秒 int minDelay 60; int delay std::max(minDelay, baseDelay - (score / 5) * 10); std::this_thread::sleep_for(std::chrono::milliseconds(delay));提示sleep的时间要算上渲染和逻辑处理的时间。如果一帧逻辑处理花了20毫秒sleep 150毫秒实际帧间隔就是170毫秒。对贪吃蛇来说这点误差无所谓但如果你要做更精确的计时就得用时间戳来算实际间隔。4. 完整实操流程与关键环节实现4.1 项目结构规划在动手写代码之前先把文件结构定下来。我习惯分成三个文件一个头文件放结构体、枚举和函数声明一个源文件放游戏逻辑一个源文件放主函数。这样职责清晰改起来也方便。snake/ ├── snake.h // 类型定义、函数声明 ├── snake.cpp // 游戏逻辑实现 └── main.cpp // 程序入口对于这么小的项目其实一个文件也能写完。但分成多个文件的好处是养成好习惯而且以后要加功能比如加个排行榜、加个菜单的时候不用在一个几千行的文件里翻来翻去。我个人的原则是只要代码超过300行就考虑拆分。4.2 数据结构定义先把基础类型定下来。一个坐标点一个方向枚举一个游戏状态结构体。游戏状态里包含蛇、食物、当前方向、分数、是否结束这些字段。struct Point { int x, y; bool operator(const Point other) const { return x other.x y other.y; } }; enum Direction { UP, DOWN, LEFT, RIGHT }; struct GameState { std::dequePoint snake; Point food; Direction dir; int score; bool gameOver; int rows, cols; };Point重载了运算符这样就能直接用std::find在deque里查找坐标省得自己写循环。这个小技巧很实用C标准库的算法配合运算符重载能让代码短很多。4.3 初始化把一切准备好初始化阶段要做几件事设置地图大小、把蛇放在中间、给个初始方向、生成第一个食物、初始化随机数种子。地图大小我一般用20行40列这个比例在控制台里看起来比较舒服太窄了蛇没地方转太宽了字符太小看不清。void initGame(GameState state) { state.rows 20; state.cols 40; state.score 0; state.gameOver false; state.dir RIGHT; // 蛇初始3节放在中间 int midX state.rows / 2; int midY state.cols / 2; state.snake.clear(); state.snake.push_back({midX, midY}); state.snake.push_back({midX, midY - 1}); state.snake.push_back({midX, midY - 2}); state.food generateFood(state.snake, state.rows, state.cols); }蛇的初始方向是向右所以身体要放在头的左边这样看起来才自然。如果方向向右但身体放在右边蛇一开局就是倒着的视觉上很别扭。这种细节不影响功能但影响体验值得注意。4.4 渲染用光标定位画出稳定画面渲染部分我前面说了用光标定位。核心思路是先画一次边框和背景之后每帧只更新蛇和食物的位置。但蛇移动时旧位置要擦掉新位置要画上。最简单的做法是每帧把整个游戏区域重画一遍但用光标定位而不是清屏这样不会闪。void render(const GameState state) { // 移动光标到左上角不清屏 setCursor(0, 0); for (int i 0; i state.rows; i) { for (int j 0; j state.cols; j) { Point p{i, j}; if (p state.snake.front()) { std::cout O; // 蛇头 } else if (std::find(state.snake.begin(), state.snake.end(), p) ! state.snake.end()) { std::cout o; // 蛇身 } else if (p state.food) { std::cout *; // 食物 } else { std::cout ; // 空白 } } std::cout \n; } std::cout Score: state.score ; }setCursor在Windows下用SetConsoleCursorPosition在类Unix下输出\033[0;0H。这个函数每帧调用一次把光标挪回左上角然后重新画整个区域。因为不清屏所以不会有闪烁。实测下来20x40的地图每帧重画完全无压力CPU占用几乎为零。4.5 主循环把三个阶段串起来主循环就是前面说的三段式输入、更新、渲染加上一个速度控制。int main() { std::srand(std::time(nullptr)); GameState state; initGame(state); while (!state.gameOver) { handleInput(state); update(state); render(state); std::this_thread::sleep_for( std::chrono::milliseconds(getDelay(state.score))); } std::cout \nGame Over! Final Score: state.score \n; return 0; }这个结构清晰到几乎不用注释。handleInput负责读键和改方向update负责移动蛇和判断碰撞render负责画。每个函数只做一件事改起来互不影响。比如你想加个暂停功能只需要在handleInput里加个判断其他函数完全不用动。4.6 编译与运行编译命令很简单用g的话g -stdc17 -O2 main.cpp snake.cpp -o snake ./snake-stdc17是因为我用了std::deque和std::chrono这些在C11之后都支持但指定17更保险。-O2开优化虽然贪吃蛇不需要什么性能但开了也没坏处。Windows下如果用MSVC把源文件加到项目里直接编译就行注意_kbhit和_getch需要包含conio.h。注意类Unix系统下用termios改终端模式后程序退出前一定要恢复原来的设置否则终端会变得很奇怪比如输入不回显。可以在程序开始时保存原设置结束时恢复或者用RAII封装一个类析构时自动恢复。5. 常见问题与排查技巧实录5.1 蛇移动时多出一节或少一节这是新手最常遇到的问题。现象是蛇移动后长度不对要么越来越长要么越来越短。根本原因通常是push_front和pop_back的配对逻辑写错了。正确的逻辑是每帧先push_front新头部然后判断有没有吃到食物。如果吃到了不删尾部蛇变长一节如果没吃到pop_back删尾部蛇长度不变。如果你把判断写在push_front之前或者忘了删尾部蛇就会一直变长。反过来如果吃到了还删尾部蛇就长不大。排查方法在移动逻辑前后打印蛇的长度看看每帧长度变化是否符合预期。正常情况是没吃到时长度不变吃到时长度加一。5.2 按方向键没反应前面提过Windows下方向键_getch()返回两次。第一次返回0或224第二次才是具体方向。如果你只读一次读到的就是0或224自然没反应。int key _getch(); if (key 0 || key 224) { key _getch(); // 再读一次 switch (key) { case 72: newDir UP; break; case 80: newDir DOWN; break; case 75: newDir LEFT; break; case 77: newDir RIGHT; break; } }类Unix系统下方向键是三个字符的转义序列比如上键是\033[A。处理起来稍微麻烦一点要读第一个字符判断是不是\033是的话再读两个字符。如果只做Windows版用_getch的方案最省事。5.3 画面闪烁严重闪烁的根源是清屏和重画之间的时间差。如果你用system(cls)整个屏幕先变黑再重画人眼就能感知到闪烁。解决办法就是前面说的光标定位不清屏只覆盖。还有一个可能的原因是输出没有刷新。std::cout是缓冲的有时候内容还在缓冲区里没输出到屏幕。可以在渲染结束后加std::cout.flush()或者用std::endl代替\n但endl会强制刷新频繁调用影响性能所以还是手动flush更好。5.4 蛇穿墙或者撞墙没判定这个通常是边界判断写错了。注意控制台的坐标系行是纵轴列是横轴。判断的时候要分别检查x和y是否在[0, rows)和[0, cols)范围内。常见的错误是把rows和cols写反或者用了而不是。还有一种情况是蛇穿墙——从一边出去从另一边进来。这不是bug是有些版本的贪吃蛇故意设计的功能。如果你不想要这个效果就在边界判断里直接判定游戏结束。如果你想要就在坐标超出范围时把它绕回另一边。两种都可以看你的设计意图。5.5 常见问题速查表现象可能原因排查方向蛇长度异常push/pop配对错误检查吃食物判断的位置方向键无反应方向键返回两次读到0或224时再读一次画面闪烁清屏重画改用光标定位撞墙没判定边界条件写错检查rows/cols和比较符号食物长在蛇身上生成时没查重加循环直到找到空位蛇一开局就死初始方向与身体位置矛盾身体放在头的反方向终端退出后异常termios没恢复用RAII或退出前恢复5.6 几个我踩过的坑第一个坑是方向输入的缓冲问题。如果玩家在一帧内快速按了多个方向键比如先按上再按右如果两个都处理蛇可能先转上再转右中间那一帧的方向就乱了。我的做法是每帧只处理一个方向输入或者用一个队列缓存每帧取一个。这样操作更跟手也不会出现诡异的转向。第二个坑是食物生成在蛇头正前方。这个不算bug但体验不好——玩家刚吃完一个下一个食物就出现在嘴边感觉像白送。可以在生成食物时排除掉蛇头周围一圈的位置让食物分布更均匀。这个纯属体验优化做不做都行。第三个坑是速度太快导致输入丢失。如果帧间隔很短玩家按键的速度跟不上蛇移动的速度就会出现按了没反应的错觉。解决办法是适当放慢速度或者在输入处理里多读几次把缓冲的按键都处理掉。我一般倾向于放慢速度因为贪吃蛇的乐趣在于策略不在于手速。6. 可以继续扩展的方向基础版本跑通之后这个项目还有很多可以玩的花样。比如加个障碍物在地图上随机放几堵墙蛇撞到就死难度立刻上一个台阶。加个特殊食物吃了能加速或者减速增加变数。加个排行榜把最高分存到文件里下次打开还能看到。再进阶一点可以把控制台渲染换成图形库比如用SDL或者SFML加上颜色、音效、动画。这时候你会发现前面写的游戏逻辑几乎不用改只需要把render函数换掉就行。这就是分层设计的好处——逻辑和渲染解耦换皮不换骨。我个人在实际操作中的体会是贪吃蛇这个项目最大的价值不在于游戏本身而在于它逼着你去思考一个程序怎么组织。当你把输入、更新、渲染这三块分清楚把数据结构和操作对应好后面写任何交互式程序都会顺手很多。这个思路是可以迁移的比记住几个API有用得多。最后再分享一个小技巧写这类项目的时候先把游戏循环的骨架搭出来让蛇能动能画哪怕逻辑不完整。然后再一点点往里填碰撞、食物、计分。这样你每一步都有能跑的东西不会写到一半发现整个结构不对要推倒重来。这种先跑通再完善的节奏比一开始就追求完美要高效得多。