☰
用C++写贪吃蛇:从数组、指针到状态机与碰撞检测的完整实战
2026/10/23 5:40:36 网站建设 项目流程

用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++,试着亲手从头写一遍,别急着抄代码,先把每一个函数为什么存在搞清楚。你会发现,写完这个项目之后,指针、数组、枚举这些概念不再是背下来的名词,而是真正属于自己的工具了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询