用C写贪吃蛇几乎是每个C学习者都会碰一次的项目。它不像写一个计算器那么枯燥也不像直接啃算法题那么劝退代码量适中逻辑闭环还能马上跑起来看到效果。我最初写这个项目是在学习C大概半年的时候当时刚把指针、数组、枚举这些概念学完正愁找不到一个能把这些知识点揉在一起的练手题目。贪吃蛇这个项目刚好合适它有状态、有输入、有碰撞检测、有实时刷新短短几百行代码就能串起一大片基础语法。这篇文章我按自己的完整实现思路来写里面包含全部核心代码、每一处设计决策的原因以及实际调试中踩过的坑。不管你是刚学完C语法的新手还是想给学生布置练手项目的开发者照着这个思路走一遍收获会非常直接。1. 项目拆解先搞清楚贪吃蛇到底难在哪1.1 贪吃蛇的本质需求把游戏剥开看贪吃蛇的核心只有四件事蛇按固定节奏移动、玩家通过按键改变移动方向、蛇头碰到食物就变长、撞墙或撞到自己就结束。听起来简单但实现时你会发现它涉及一个完整的小型状态机蛇当前的方向是状态食物的位置是状态蛇身的每一节坐标是状态游戏是否结束也是状态。所有逻辑都是围绕这些状态做更新和判断。还有一个容易被新手忽略的点蛇的移动并不是一步一步“走格子”而是每一帧把蛇头向前推进一格蛇身整体跟着向前挪一格尾部如果这一帧没吃到食物就丢掉一格。换句话说蛇身的长度在没吃到食物时保持不变吃到食物时整体加一。理解这一点后面数据结构的选择和移动算法的实现就顺理成章了。1.2 为什么选C来练这个项目用C写贪吃蛇核心价值在于语法覆盖面广。你会用到枚举来定义方向用数组或指针管理蛇身坐标用随机数生成食物位置用控制流组织主循环还可能用到结构体或类做数据封装。如果只把目标定为“跑起来”选择很多但C的优势是贴近底层你能清晰看到内存和数据结构的运作方式。比如蛇身用定长数组和用链表实现性能特征和代码复杂度是完全不同的这种对比在脚本语言里很难体会到。另外C的编译型特性让“调试”这件事变得特别真实。指针越界、数组下标错乱、输入缓冲残留每一个问题都要你亲手定位。写贪吃蛇的过程本质上就是一次完整的调试训练。我见过不少人说“C语法都学了就是不知道能做什么”贪吃蛇就是一个能把语法转化为程序逻辑的桥梁项目。1.3 控制台版和图形版怎么选我第一次做选择的是控制台版也就是在命令行窗口里用字符画地图用键盘方向键控制蛇移动。控制台版的优势是代码依赖极少核心逻辑不会被复杂API干扰非常适合学习阶段。它需要关心的只有三件事地图怎么画、键盘输入怎么读、游戏循环怎么写。图形版则是之后可以考虑的升级方向比如用图形库画一个带颜色、带音效的完整游戏界面但图形库API会占掉大量篇幅核心游戏逻辑反而被淹没。我的建议很直接第一版必须是控制台版把逻辑彻底想明白之后再考虑图形封装。很多人一上来就折腾图形界面最后卡在窗口初始化和绘图调用上游戏逻辑反而没来得及好好写本末倒置。2. 核心设计决策数据结构、移动逻辑和方向控制2.1 蛇身坐标用什么存这是整个项目最关键的设计决策。最直觉的想法是保存蛇头坐标再加上一个代表长度的整数每一节身体实时计算位置。但问题是蛇身是弯曲的你不可能通过简单的公式推导每一节在哪所以必须保存所有身体节的坐标。常见的方案有三种定长数组、链表、动态数组(比如容器)。我在第一版里用的是定长数组原因很朴素地图大小是固定的蛇身最长也不可能超过地图格子总数。以20x20的画面为例满地图也只有400个格子所以开两个长度为400的数组分别存所有身体节的X和Y坐标完全足够。数组的好处是访问随机、写起来直观、不会有内存管理负担。链表和动态数组更适合做动态伸缩的蛇身逻辑上也更灵活但会引入额外的复杂度和内存操作对于练手项目来说定长数组是性价比最高的方案。如果你是那种喜欢把代码写得“优雅”的人完全可以用链表实现蛇身每一节是一个节点吃到食物就在头部插入新节点移动时删除尾部节点。这确实是经典的数据结构应用题但第一版别这么做先用数组把游戏跑通之后再用链表重构你才能对比出两种方案的实际差异。2.2 蛇移动的本质是一行状态转移很多新手在这里容易卡住怎么让整条蛇流畅地跟着头走我换个方式说就清楚了。蛇的移动等同于做一次数组的“整体后移”每次移动时把蛇头当前坐标存入蛇身数组的第一位之后的每一节身体都挪到前一节的位置上然后蛇头根据当前方向前进一格。如果这一帧没有吃到食物就再把蛇身最后一位“丢弃”等效于长度不变如果吃到了食物保留最后一位蛇身长度加一。这个思路可以用一个简单的过程描述从尾巴开始每一节移动到它前面那一节的旧位置最后蛇头移动到新位置。实际编码时我用的是先把旧蛇头坐标推入蛇身数组再移动蛇头最后根据是否吃到食物决定要不要丢弃尾部。这套逻辑写起来只有几行但它是整个贪吃蛇的核心理解了它其他部分都是围绕这个主循环打转。2.3 方向控制与防回头处理方向控制表面上很简单读到方向键就改方向变量。但有一个我们必须处理的规则蛇不能直接掉头比如当前正在向右移动玩家按左键蛇不应该立刻反向否则蛇头会直接穿进自己的脖子立刻游戏结束。大多数实际上手时都会因为这种误操作莫名其妙地死掉。处理方式是每次按键时加一个判断只有新方向和当前方向不是相反方向时才允许改变方向。在代码里体现为按左箭头时只有当前方向不是向右才改成向左。这里有一个需要小心的点当游戏刚开始、蛇还没有移动时当前方向是“停止”这时按任意方向都应该生效所以判断条件必须兼容“停止”状态。还有一个常见设计是把方向定义成枚举通过比较枚举值来判断是否相反但要注意键盘方向键的编码因为不同平台会有差异这点后面讲跨平台时会详细说。3. 从零手写完整实现与逐段解读3.1 准备工作环境与头文件我使用的是一个最基础的开发环境Windows系统任意支持C的编译器比如常见的一些IDE或直接命令行编译。控制台版贪吃蛇会用到一个在Windows下特有的头文件conio.h它提供_kbhit()和_getch()两个函数分别用来检测键盘是否有输入、读取输入的字符。此外还用到windows.h里的Sleep()函数控制游戏节奏。需要说明的是这两套头文件不是C标准库的内容是Windows平台编译器提供的扩展。Linux或macOS下直接编译这段代码会报错解决办法有两个一是用跨平台的键盘输入和延时方案替代二是改用图形库方案绕开控制台输入问题。初学者在Windows下写第一版最省事直接使用这些头文件后面需要跨平台再改造。3.2 全局变量和游戏初始化代码的第一步是定义地图尺寸、蛇身数组、方向枚举等全局变量。地图我用20x20画面四周用符号围起来作为墙。蛇身最大长度理论上不能超过内部格子总数为了保险数组大小直接开到地图总格子数WIDTH * HEIGHT。初始化函数里做的事按顺序包括设置游戏未结束、方向为停止、蛇头放在画面正中间、蛇身长度为0、分数为0、随机生成第一个食物。这里要注意随机数种子务必用当前时间做种子否则每次启动游戏食物位置都是一样的。食物生成我单独写成一个函数。为什么要单独拆出来因为游戏过程中每吃到一次食物都要重新生成拆成函数可以复用。生成逻辑有一个关键细节新食物不能出现在蛇身上也不能出现在蛇头当前所在的位置否则玩家还没操作就卡住了。我的做法是循环随机生成坐标然后遍历蛇身数组检查是否重合如果重合就重新生成。地图只有400格这个操作对性能毫无影响但如果蛇身已经占了大部分地图光靠随机碰运气可能会循环很多次严谨一点可以加一个最大尝试次数限制尝试失败就直接结束游戏因为此时实际上已经没有空位了。3.3 画面绘制字符地图与刷新控制台版贪吃蛇的“画面”本质上是每次循环都重新往控制台打印一遍整个地图。我用system(cls)清屏然后按行打印字符。最外圈打印#表示墙内部根据坐标打印蛇头、蛇身、食物或空格。这里有一个优化点值得提反复清屏再打印会导致画面闪烁这是控制台游戏的常见毛病。如果只是练手闪烁可以接受如果想让画面更稳可以把光标定位到固定位置再重绘而不是整个清屏或者直接在内存里拼好一整屏的字符矩阵再一次性输出。这些优化我放在后面的扩展部分讲第一版先用清屏方式代码最短最好懂。绘制时我给蛇头、蛇身、食物用了不同字符方便区分蛇头用大写O蛇身用小写o食物用F。绘制顺序是先判断当前位置是不是蛇头再判断是不是食物最后判断是不是蛇身。为什么食物判断排在蛇身之前因为食物不可能和蛇身在同一格判断顺序其实没有影响但蛇头如果和食物同格说明这一帧吃到食物了理论上应该在Logic中处理完后才会进入下一帧绘制所以蛇头和食物也不会同格。按先蛇头再食物再蛇身的顺序写最直观。3.4 输入处理与逻辑更新输入处理的核心是_kbhit()配合_getch()。_kbhit()检查当前有没有按键按下如果有_getch()读取一个字符。为什么不用标准库的cin因为cin会阻塞等待输入并且要按回车才会生效完全不符合实时游戏的需求。_getch()是非阻塞读取配合_kbhit()可以实现“有按键就响应没按键就继续跑”的实时循环。这里有一个新手特别容易遇到的问题按下方向键时_getch()会返回两个字节第一个字节是224第二个才是方向键的扫描码。所以必须连续读取两次第一次判断是不是方向键前缀第二次读取真正的方向码。我在代码里用一个switch分别处理w、s、a、d四个字符键以及方向键的扫描码。只处理字符键也可以玩但既然做了就把方向键也支持上手感更好。逻辑更新函数是整个游戏的大脑执行顺序如下如果当前方向是停止就什么都不做把蛇头当前坐标存入蛇身数组同时蛇身长度加一按方向移动蛇头判断蛇头是否撞墙判断蛇头是否撞到自己身体判断蛇头是否吃到食物如果吃到就加分并重新生成食物否则把蛇身长度恢复原值等效于丢弃尾部。这个顺序是经过多次调整定下来的。最需要注意的是“先存蛇头再移动蛇头”的顺序如果反了蛇身数组拿到的就不是“移动前的蛇头坐标”整条蛇的跟随逻辑就错了。另外碰撞检测要在移动蛇头之后做检测的才是蛇头的新位置。曾经我把碰撞检测放在移动之前结果蛇头明明撞墙了还要多走一帧才判定结束体验非常奇怪。3.5 完整代码参照下面是我最终跑通的完整代码去掉注释后大约150行结构清晰每个函数只做一件事。直接复制到一个空的C源文件里在Windows环境下编译运行就能玩起来。#include iostream #include conio.h #include windows.h #include cstdlib #include ctime using namespace std; const int WIDTH 20; const int HEIGHT 20; const int MAX_TAIL WIDTH * HEIGHT; int headX, headY; // 蛇头坐标 int foodX, foodY; // 食物坐标 int tailX[MAX_TAIL]; // 蛇身 X 坐标 int tailY[MAX_TAIL]; // 蛇身 Y 坐标 int tailLen; // 蛇身长度 int score; // 分数 bool isOver; // 游戏是否结束 enum Direction { STOP 0, LEFT, RIGHT, UP, DOWN }; Direction dir; // 当前方向 void PlaceFood() { while (true) { foodX rand() % WIDTH 1; foodY rand() % HEIGHT 1; bool collision false; for (int i 0; i tailLen; i) { if (tailX[i] foodX tailY[i] foodY) { collision true; break; } } if (!collision !(headX foodX headY foodY)) { break; } } } void Setup() { isOver false; dir STOP; headX WIDTH / 2; headY HEIGHT / 2; tailLen 0; score 0; srand((unsigned)time(NULL)); PlaceFood(); } void Draw() { system(cls); for (int i 0; i WIDTH 1; i) { cout #; } cout endl; for (int i 1; i HEIGHT; i) { for (int j 1; j WIDTH; j) { if (j 1) { cout #; } if (i headY j headX) { cout O; } else if (i foodY j foodX) { cout F; } else { bool isTail false; for (int k 0; k tailLen; k) { if (tailX[k] j tailY[k] i) { cout o; isTail true; break; } } if (!isTail) { cout ; } } if (j WIDTH) { cout #; } } cout endl; } for (int i 0; i WIDTH 1; i) { cout #; } cout endl; cout Score: score endl; } void Input() { if (_kbhit()) { int key _getch(); if (key 224) { key _getch(); switch (key) { case 72: if (dir ! DOWN) dir UP; break; case 80: if (dir ! UP) dir DOWN; break; case 75: if (dir ! RIGHT) dir LEFT; break; case 77: if (dir ! LEFT) dir RIGHT; break; } } else { switch (key) { case w: if (dir ! DOWN) dir UP; break; case s: if (dir ! UP) dir DOWN; break; case a: if (dir ! RIGHT) dir LEFT; break; case d: if (dir ! LEFT) dir RIGHT; break; case x: isOver true; break; } } } } void Logic() { if (dir STOP) { return; } tailX[tailLen] headX; tailY[tailLen] headY; tailLen; switch (dir) { case LEFT: headX--; break; case RIGHT: headX; break; case UP: headY--; break; case DOWN: headY; break; default: break; } if (headX 0 || headX WIDTH 1 || headY 0 || headY HEIGHT 1) { isOver true; return; } for (int i 0; i tailLen; i) { if (tailX[i] headX tailY[i] headY) { isOver true; return; } } if (headX foodX headY foodY) { score 10; PlaceFood(); } else { tailLen--; } } int main() { Setup(); while (!isOver) { Draw(); Input(); Logic(); Sleep(100); } cout Game Over! Final Score: score endl; return 0; }这段代码有几个设计上的关键点值得强调。撞墙判定用的是边界坐标判断地图内部有效坐标是从1到WIDTH因此蛇头坐标小于等于0或大于等于WIDTH 1就算撞墙这个边界逻辑一定要和绘制地图时保持一致。吃到食物时我直接调用PlaceFood()重新生成食物而蛇身长度已经在上面的“存入旧头坐标并加一”步骤中增加了所以不需要额外再加长度。没吃到食物时我把tailLen减回去等于把蛇身数组末尾闲置的那个槽位丢弃逻辑上就是“这一帧尾巴往前挪了一格”。3.6 主循环顺序为什么是“绘制-输入-逻辑”主循环里三个函数按Draw - Input - Logic的顺序执行这个顺序不是随便定的。先绘制保证玩家看到的画面是当前帧的完整状态再读输入把玩家的操作更新到方向变量上最后执行逻辑让蛇根据方向移动产生下一帧的状态。下一轮循环再重新绘制时画的就是最新的状态。如果调换顺序比如先逻辑后绘制那么第一帧会先移动蛇头再画界面玩家启动游戏时看到的蛇就已经偏离初始位置了感觉像莫名其妙跳过了一步。我实际调试时试过各种顺序最后定下来这个顺序最符合直觉。Sleep(100)放在循环末尾控制每帧间隔100毫秒也就是每秒大约10格移动速度。这个数值不是固定的想加快就把数值调小想减慢就调大。4. 实际操作中踩过的坑问题排查与细节优化4.1 按住方向键时蛇移动明显变快这个现象我第一次玩自己写的游戏时立刻发现了。原因是_kbhit()会持续检测按键键盘缓冲区里有多个相同按键时每一帧循环都会读到同一键并处理但方向变量没有变化为什么不加速呢问题出在部分键盘的按键会在按住时快速重复触发。当你快速连按某个方向键时输入可能被吞掉或延迟累积导致蛇突然加速。更根本的问题是每一帧都调用一次_kbhit()和_getch()但Sleep只发生在帧末帧内一旦有多次按键方向上不会有额外变化所以理论上不会加速。可我实测发现方向键和w字符键混用时偶尔会出现一次按键被拆成两个事件的情况导致方向被改两次视觉上蛇会突然变快甚至跳格。后来我做了个简单处理在Input()里用一个循环把键盘缓冲区里剩余的按键全部清空只保留最后一个方向输入。这相当于每帧只处理一次有效按键类似“输入合并”。代码层面只需要在_getch()读取后不断while(_kbhit()) _getch();清空多余输入实测手感稳定很多。4.2 初始方向为停止时撞墙判定异常我遇到的一个具体问题是游戏开始后未按任何方向键蛇停在画面中间但蛇头坐标正好和某个蛇身坐标重合导致瞬间判负。原因是初始状态tailLen为0按理不会自撞但后期反复回看代码发现当dir STOP时Logic()里会把蛇头坐标加入蛇身数组再移动蛇头如果方向是停止蛇头坐标没有变化然后自撞检测遍历到新加入的这个坐标必然判定撞到自己。解决办法就是我在Logic()开头加的那句if (dir STOP) return;游戏没有启动时不执行任何逻辑更新。这个判断同时避免了吃食物、撞墙等状态在未开始时被误触发。看起来是个很小的细节但很容易被忽略一旦遇到“开局秒死”的问题第一个检查点就是这里。4.3 食物刷在墙外面最初我生成食物用的是rand() % (WIDTH - 2) 1想着地图最外一圈是墙食物应该生成在内部。但实际绘制时我的内部坐标范围是1到20而食物只生成了1到18导致每次地图最下面两行和右边两列永远不会出食物游戏体验明显不对。后来我把生成范围直接改成rand() % WIDTH 1让食物范围覆盖1到20撞墙判定是小于等于0或大于等于WIDTH 1所以坐标等于20完全合法。这个修改很小但定位时花了不少功夫主要是我一直怀疑是随机数种子的问题而不是范围边界的问题。另一个需要同时处理的是食物不能生成在蛇头位置否则玩家还没开始动蛇头就和食物重叠下一帧如果移动方向不对吃食物逻辑会异常。我的PlaceFood()把蛇头坐标也加入碰撞检查同时遍历蛇身数组双重保证。4.4 数组越界导致的随机崩溃定长数组有个绕不开的问题蛇身长度不能超过数组大小。虽然理论上最大长度不会超过地图格子数但我一开始把MAX_TAIL定义成了100而不是WIDTH * HEIGHT。结果玩到后期蛇比较长时程序突然崩溃或画面错乱排查了很久才发现是蛇身长度超过100数组越界写入了未知内存。这类问题在控制台里很难直接看到可能表现为分数异常、地图乱画甚至直接闪退。建议大家第一版就把数组开成WIDTH * HEIGHT既不会浪费多少内存又彻底排除了越界隐患。如果想更稳健可以在tailLen增长前加一个上限判断超过上限直接结束游戏避免未定义行为。4.5 速度和难度曲线太单调第一个版本玩起来有个非常明显的感受全程速度一样开始觉得慢后期蛇长了还是这个速度挑战性不足。经典的贪吃蛇设计应该是蛇身越长速度越快或者分数越高速度越快。修改方法是在主循环里让Sleep的延时随着蛇身长度动态变化比如Sleep(max(50, 150 - tailLen * 2))蛇身每增加一节间隔就缩短一点但设定一个最小间隔防止快到无法操作。这个改动很小但对游戏体验的提升非常直接。我做完之后分别跑了一遍明显感觉紧张感上来了。另外还可以加入“吃一个食物加10分但每吃两个食物速度提升一档”这样更精细的规则这些都属于可玩性扩展不涉及核心架构变化。5. 扩展方向图形界面与代码重构5.1 从控制台到图形界面的三种路径控制台版跑通之后很多人会想把它变成真正“像样”的游戏也就是图形界面。这一步我的建议不是推翻重写而是尽量复用已有的核心逻辑。有三种常见的图形方案适用场景不同。第一种是轻量级图形库比如简单易上手的绘图库这类库直接在窗口上画矩形和文本吃透核心逻辑的人很快就能把控制台绘制替换成图形绘制。第二种是成熟游戏框架适合想学习完整游戏循环、事件处理、精灵动画的开发者它的学习曲线更陡但功能完善。第三种是自绘窗口方案用系统窗口API自己处理消息循环这个方式代码量大纯粹为了技术研究不建议作为贪吃蛇项目的首选。我实际做图形版时选择的是轻量级图形库因为最贴近控制台版的逻辑结构只需要把“按坐标打印字符”改成“按坐标画色块”游戏机制完全不变。整个改造大概花了半天核心的Logic()一行没动只重写了绘制和输入部分。这也验证了初始设计时“逻辑和表现分离”的价值。5.2 面向对象重构把数据和绘制拆开控制台版为了追求简短用了大量全局变量这在练手阶段没问题但继续扩展时全局变量会越来越难管理。图形版改造前我建议先做一次简单的面向对象重构。最基本的分层是一个类负责游戏状态包括蛇、食物、方向、得分、碰撞逻辑另一个类负责界面表现包括绘制和输入。两者之间通过公开接口交互而不是共享全局变量。这样重构后最大的好处是你可以写一个没有界面依赖的纯逻辑测试把蛇放在某个地图里模拟一系列输入直接断言游戏结果。想做自动化测试或者进一步加AI自动寻路功能都会轻松很多。底层逻辑独立出来后甚至可以不做任何改动从控制台版切换到图形版或者再切到网页版游戏规则都是同一套代码。5.3 还能加哪些功能除了换皮肤我建议按优先级加这三个功能。障碍物地图里随机生成静态障碍蛇头撞上就结束难度立马上来实现时只需要在碰撞检测加一个数组坐标判断。加速道具食物有概率变成特殊道具吃了之后短时间内速度变快或得分翻倍调味效果很好实现时增加一个道具状态变量。排行榜把最高分写入本地文件每次启动读取并显示。这个功能虽然简单但会让项目有完整版的感觉也能练习一下文件读写。如果还想挑战更高的难度可以加边界循环模式蛇从一侧穿出从另一侧出现不再因为撞墙结束。这个模式在控制台版里实现也不复杂只需要对蛇头坐标做取模运算。但要注意穿墙后蛇身坐标的连续性如果处理不好蛇身会断裂。我的建议是先别做穿墙把经典模式玩明白这些扩展都是水到渠成的事。最后我想说贪吃蛇这个项目我从控制台版写到图形版前后折腾了很多遍。它给我带来的最大收获不是“我会写游戏了”而是我终于明白了程序里“状态更新”到底是什么意思。那些看似复杂的游戏机制拆到最小粒度都是一个个坐标、一个个判断、一次次赋值。如果你正在学C试着亲手从头写一遍别急着抄代码先把每一个函数为什么存在搞清楚。你会发现写完这个项目之后指针、数组、枚举这些概念不再是背下来的名词而是真正属于自己的工具了。
阅读完成 · 觉得有帮助?