用VS和C++写一个贪吃蛇游戏,听起来很基础,但真做起来,里面藏着不少值得抠的细节。我把完整源码放在下面,你在Visual Studio里新建一个控制台应用,复制进去就能编译运行。对刚学完C++语法、想做点东西验证自己的入门者来说,贪吃蛇几乎是最理想的第一炮——它不涉及复杂框架,不依赖图形资源,却又把数组、容器、循环、分支、状态机这些核心知识点全串起来了。这篇文章我会从项目定位、设计思路、核心实现到编译运行,完整拆一遍,最后还附上可直接复制的源码和常见问题排查表。
1. 项目定位:为什么我推荐用VS+C++写贪吃蛇
1.1 贪吃蛇是C++入门阶段的“试金石”
很多学C++的人都有过这种感受:语法书看了好几章,指针、引用、类都背得滚瓜烂熟,但真让自己写一个程序,反而不知道从哪里下笔。贪吃蛇恰好能打破这种尴尬。它规模不大,核心逻辑两三百行就能写完,但又没有简单到“照着书抄一遍”的程度。
我在实际接触过一些初学者后发现,能把贪吃蛇完整写出来并调通的人,对C++的基础语法往往会有一次质的提升。原因很简单:贪吃蛇需要你同时处理动态数据(蛇身长度会变)、实时输入(方向键)、状态判断(吃到食物、撞墙、撞自己)以及界面刷新。这些点单独拿出来都不难,但组合在一起,就是对逻辑组织和代码结构的一次真实考验。
换句话说,如果你把贪吃蛇写明白了,再去碰学生管理系统、通讯录、图书借阅这类典型课程设计,基本就是降维打击。因为那些系统本质上是数据的增删改查,难度反而没有贪吃蛇这种“实时变化”的游戏高。
1.2 这套源码适合哪些人直接上手
先说结论:适合已经学过C++基本语法,但还没独立写过完整小项目的人。你需要知道变量、数组、循环、函数、结构体这些概念,不需要精通,能看懂就行。
如果你是完全没接触过C++的纯零基础,我不建议直接拿这份源码硬啃。不是说代码有多难,而是你可能会在编译报错上浪费太多时间,产生挫败感。你可以先找一本基础书,把循环、数组、结构体这几个章节看完,再回来读这份代码,会发现顺畅很多。
如果你已经写过不少控制台程序,这版贪吃蛇对你来说可能偏简单。但你仍然可以把它作为基础版本,在其上做扩展——比如增加关卡、计分板、障碍物,或者把控制台输出改成图形界面。后面我会专门讲扩展方向。
1.3 技术选型:VS加控制台,是我刻意做的选择
有不少人问过我:为什么不用图形库?用EasyX或者Qt画个界面不是更好看吗?我承认,图形界面确实更漂亮,但我不建议入门阶段一上来就碰。
原因有三个。第一,Visual Studio是Windows下最主流的C++开发环境,新建一个控制台应用项目只需要几秒钟,不需要额外配置第三方库。第二,控制台程序能让你把注意力全部放在游戏逻辑上,不会被窗口消息、坐标映射、图片加载这些图形相关的东西干扰。第三,控制台的字符绘制虽然粗糙,但足以帮助你建立“程序输出等于游戏画面”的抽象思维——这点对以后写任何界面程序都有帮助。
从环境成本看,只要电脑上装了Visual Studio,打开就能跑。这也是我标题里写“VS编译就能运行”的原因。整个项目没有任何外部依赖,不装额外的包,不配环境变量,非常适合新手自己动手搭环境。
2. 整体设计思路:动手前先拆解这四个模块
2.1 核心机制:蛇怎么动、吃什么、怎么死
贪吃蛇的规则用一句话就能说完:控制一条蛇在地图里移动,吃到食物变长,撞墙或撞到自己就结束。
但“一句话说完”和“写出程序”之间隔着很多细节。我习惯拆成三个问题来想:
- 蛇的位置怎么表示?地图是二维的,蛇是由很多个“点”组成的,每个点有x和y坐标。
- 蛇怎么移动?每一帧,蛇头朝当前方向前进一步,蛇身跟着往前挪,尾巴如果没吃到食物就要缩掉。
- 游戏状态怎么判断?每一帧都要检查新蛇头的位置是不是越界了、是不是和身体重叠了、是不是踩到了食物。
把这三个问题想清楚,代码结构自然就出来了。我见过不少人一上来就写绘制逻辑,结果蛇动不了、方向乱跳、撞墙不结束。根源就是没先想清楚移动模型。
2.2 数据结构:用数组固定长度,还是用容器动态管理
蛇的特点是长度会变化。吃一个食物变长一格,没吃食物时保持不变。这意味着我们最好用一个能动态增删的数据结构,而不是先申请一个固定大小的二维数组。
在这版代码里,我用的是vector<Point>。Point是一个包含x、y的结构体,vector负责存放所有蛇身的坐标点,头部在第一个元素。移动的时候,往头部插入新坐标;没吃到食物时,把尾部删掉。一次移动就完成了。
可能有人会问:用链表是不是更合适?从理论上讲,链表做“头插尾删”确实很自然。但考虑到这是入门项目,vector的随机访问能力让绘制和碰撞检测更直观,而且初学者对vector的接受度普遍更高。用链表当然可以,但没必要在这个阶段徒增复杂度。
地图本身我用字符边界表示。地图宽20、高20,四周用#画一圈围墙。这个大小可以直接在代码里改,想地图大一点,改两个常量就行。
2.3 状态流转:游戏循环是整台戏的发动机
控制台游戏本质上是一个死循环:读取输入、更新状态、绘制画面、等待一段时间,然后重复。这跟Web程序里的消息循环、游戏引擎里的主循环是一个套路。我把这个循环戏称为“游戏发动机”,因为它每转一圈,画面就刷新一次。
伪代码大概是这样的:
while (游戏未结束) { 处理键盘输入; 计算蛇的新位置; 判断是否吃到食物; 判断是否撞墙或撞自己; 更新蛇的坐标; 清屏并重新绘制; 延时控制速度; }这个循环看起来简单,但顺序很重要。如果先绘制再更新,画面会慢半拍;如果先更新再读输入,按键灵敏度会有问题。我建议新手先照着我源码里的顺序写,等理解了再自己调整。
3. 核心细节解析:运行效果背后的关键逻辑
3.1 地图、蛇身和食物的表示方法
我定义了一个常量W和H,分别表示地图宽度和高度。地图不是用二维数组存整张网格的,而是靠“位置比对”来绘制。什么意思?每一帧刷新时,从头到尾遍历地图上的每个格子,判断这个格子是不是蛇身、是不是蛇头、是不是食物,然后输出对应字符。
这样做的好处是代码简单,坏处是绘制效率偏低。但对20×20的小地图来说,性能根本不是问题,反而更容易理解。你不需要去维护一张“地图数组”,只需要维护蛇的坐标列表和食物的坐标。
蛇的每个节点是一个结构体:
struct Point { int x, y; };食物也是一个Point,只是它单独存在,不放进蛇的列表里。绘制时,蛇头用大写字母O表示,蛇身用小写o表示,食物用*表示,空白就是空格。这样即使不做任何美化,也能一眼看清游戏状态。
3.2 移动算法:头插尾删,一个动作搞定
蛇的移动是整个程序最核心的部分。我的做法非常直接:根据当前方向计算出新蛇头坐标,然后判断情况。
如果没有吃到食物,就把蛇尾删掉,再把新蛇头插到最前面。如果吃到了食物,不删蛇尾,直接插入新蛇头。这样蛇身长度刚好加一。
这里有一个细节值得单独拎出来说:判断“吃到食物”要在“删尾”之前,因为吃到了食物时尾巴不缩。如果先删尾再判断,就会把吃到食物后的长度搞错。
示例逻辑:
Point head = snake[0]; head.x += dirX; head.y += dirY; bool eat = (head.x == food.x && head.y == food.y); if (!eat) { snake.pop_back(); } // 之后检查是否撞到自己,再把head插入snake.begin()先算新头,再决定删不删尾,最后插入新头。这三步顺序不能乱。
3.3 方向键输入与掉头限制
控制方向用的是_getch()读取键盘,方向键在Windows控制台上比较特殊。第一次调用会返回224(是一个功能键前缀),第二次调用才会返回具体的方向码:上72、下80、左75、右77。
如果你不用_kbhit()先检测键盘是否有输入,程序会在读取时阻塞住,游戏画面就没法刷新了。所以我在主循环里先调用_kbhit(),如果有按键才去读_getch()。这个“轮询式”读取方式非常经典,控制台小游戏几乎都这么写。
另一个很容易踩坑的地方是“掉头限制”。如果蛇正在向右移动,你按了左键,蛇不能直接反向,否则会瞬间撞到自己的身体。解决办法是在改变方向之前,判断当前方向:
case 75: if (dirX != 1) { dirX = -1; dirY = 0; } break;这里的dirX != 1表示当前不是在向右移动,所以允许向左。其他方向同理。这个判断看似简单,但如果没有它,玩家按两下反向键,蛇就会原地穿过自己,游戏逻辑就崩了。
3.4 食物生成与自撞检测的边界处理
食物生成看似简单:随机生成一个坐标就行。但这里有个隐藏问题:如果食物刚好生成在蛇身上,玩家就永远吃不到它。所以生成之后必须检查是否和蛇身重叠,如果重叠就重新生成。
我用了一个do...while循环,反复生成直到不重叠。但如果蛇身占据了大半个地图,理论上存在死循环风险。入门版本可以接受,真要严谨,可以限制尝试次数,超过次数就提示获胜。不过20×20的地图上,这种情况很难出现。
再说自撞检测。每次移动后,新蛇头会与蛇身列表做一次遍历比较。如果新蛇头的坐标出现在蛇身里,就判定游戏结束。
需要注意“尾巴”的情况。如果这一帧没有吃到食物,蛇尾已经被删掉了,那么新蛇头移动到原来尾巴的位置是允许的。经典的贪吃蛇规则允许这种情况。我的代码里是在检查自撞之前,先完成了删尾操作,因此不会误判。
3.5 绘制与游戏循环:S色的世界也能玩出节奏感
绘制部分我用system("cls")清屏,然后逐行输出地图边框、蛇、食物和分数。清屏再输出的方式简单粗暴,但确实有效,而且符合新手预期。
每次循环结束,调用Sleep(100)让程序暂停100毫秒。这个值决定了游戏速度。100毫秒对20×20地图来说比较适中,不会快得让人反应不过来,也不会慢到让人着急。想调速度,直接改这个数字就行。
如果你想让游戏越来越快,可以每得10分减少Sleep的时间。我给的源码里没有做这个,但在后面的扩展建议里会提到。
4. 完整源码与VS运行步骤:拿到就能跑
4.1 Visual Studio版本与项目创建
我用的是Visual Studio Community版,其他版本也没问题。创建项目时注意选择“创建新项目”,然后选“控制台应用(C++)”,项目名称随便起,比如SnakeGame。VS会自动生成一个包含main.cpp的空项目。
这里有一个容易踩坑的点:在VS中创建C++控制台项目时,如果有“解决方案和项目”放在同一个目录的选项,默认即可,不需要特别改。新建完项目后,打开main.cpp,把里面的默认代码全部删掉,替换成下面的源码就行。
4.2 完整源码(可复制)
下面就是我在本地跑通的完整代码。不依赖任何第三方库,只需要Windows自带的头文件,VS2022、VS2019都能直接编译。
#include <iostream> #include <conio.h> #include <windows.h> #include <vector> #include <cstdlib> #include <ctime> using namespace std; const int W = 20; const int H = 20; struct Point { int x, y; }; int main() { srand((unsigned int)time(NULL)); // 初始化蛇:三个节点,方向朝右 vector<Point> snake; snake.push_back({ W / 2, H / 2 }); snake.push_back({ W / 2 - 1, H / 2 }); snake.push_back({ W / 2 - 2, H / 2 }); // 生成初始食物,避免落在蛇身上 Point food; bool foodOk = false; while (!foodOk) { food.x = rand() % W; food.y = rand() % H; foodOk = true; for (size_t i = 0; i < snake.size(); i++) { if (snake[i].x == food.x && snake[i].y == food.y) { foodOk = false; break; } } } int dirX = 1, dirY = 0; bool gameOver = false; int score = 0; while (!gameOver) { // 判断键盘输入 if (_kbhit()) { int key = _getch(); if (key == 224) { // 方向键的前导值 key = _getch(); switch (key) { case 72: // 上 if (dirY != 1) { dirX = 0; dirY = -1; } break; case 80: // 下 if (dirY != -1) { dirX = 0; dirY = 1; } break; case 75: // 左 if (dirX != 1) { dirX = -1; dirY = 0; } break; case 77: // 右 if (dirX != -1) { dirX = 1; dirY = 0; } break; } } else if (key == 32) { // 空格暂停:清空缓冲后等待下一次按键 while (_kbhit()) _getch(); _getch(); } } // 计算新蛇头 Point head = snake[0]; head.x += dirX; head.y += dirY; // 撞墙检测 if (head.x < 0 || head.x >= W || head.y < 0 || head.y >= H) { gameOver = true; break; } bool eat = (head.x == food.x && head.y == food.y); // 没吃到食物时,先去尾巴 if (!eat) { snake.pop_back(); } else { score += 10; // 重新生成食物,避开蛇身 bool newFoodOk = false; while (!newFoodOk) { food.x = rand() % W; food.y = rand() % H; newFoodOk = true; for (size_t i = 0; i < snake.size(); i++) { if (snake[i].x == food.x && snake[i].y == food.y) { newFoodOk = false; break; } } } } // 自撞检测(此时尾巴已经按需删除) for (size_t i = 0; i < snake.size(); i++) { if (snake[i].x == head.x && snake[i].y == head.y) { gameOver = true; break; } } if (gameOver) { break; } // 新蛇头入列 snake.insert(snake.begin(), head); // 绘制 system("cls"); for (int i = 0; i < W + 2; i++) cout << "#"; cout << endl; for (int y = 0; y < H; y++) { cout << "#"; for (int x = 0; x < W; x++) { bool isSnakeNode = false; for (size_t i = 0; i < snake.size(); i++) { if (snake[i].x == x && snake[i].y == y) { if (i == 0) cout << 'O'; else cout << 'o'; isSnakeNode = true; break; } } if (!isSnakeNode) { if (food.x == x && food.y == y) cout << '*'; else cout << ' '; } } cout << "#" << endl; } for (int i = 0; i < W + 2; i++) cout << "#"; cout << endl; cout << "Score: " << score << endl; cout << "Use Arrow Keys. Space to pause." << endl; Sleep(100); } system("cls"); cout << "Game Over!" << endl; cout << "Final Score: " << score << endl; return 0; }代码复制进main.cpp后,直接按Ctrl+F5(开始执行而不调试)就能运行。如果弹出安全提示,选择“是”允许即可。
4.3 如果编译报错,先看这里
我在本地测试时非常顺利,但你换一台电脑或多一个步骤,就有可能遇到几个常见问题。先列两个最典型的。
一是_kbhit或_getch提示找不到标识符。这通常是头文件没包含对,或者项目属性里“字符集”设置出了问题。检查一下有没有#include <conio.h>,以及“配置属性 → 常规 → 字符集”是否设置为“使用多字节字符集”。如果这里设成了Unicode,部分老的C风格头文件可能出现兼容问题。
二是vector或者cout报错。这是因为没有包含<vector>或<iostream>。VS的空模板可能不会自动带入这些头文件,所以检查源码顶部的几个#include,不要删掉。
5. 常见问题与排查技巧实录
5.1 高频报错对照表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 编译时报“_kbhit”: 找不到标识符 | 缺少conio.h或字符集设置不对 | 包含头文件,并把字符集设为多字节 |
| 界面一闪而过,看不到游戏画面 | 程序运行完自动关闭控制台 | 用Ctrl+F5运行,或代码结束前加system("pause") |
| 方向键按下没反应 | 方向键的前导值224处理不当 | 确认if (key == 224)后再次调用_getch()读取方向码 |
| 蛇移动速度太快,来不及反应 | Sleep时间太短 | 把Sleep(100)改成更大的数值,如150或200 |
| 蛇反向时瞬间死亡 | 缺少“禁止掉头”判断 | 确保每个方向键分支都检查当前方向,不允许直接相反 |
| 食物生成在蛇身上或地图外 | 随机范围不对或没有检查重叠 | 使用rand() % W和rand() % H,并做重叠循环检查 |
这张表基本覆盖了我见过的大部分问题。如果你遇到的报错不在这张表里,优先看编译窗口里的红色错误信息,它会定位到具体行号。多数情况下,问题出在漏了头文件、少了分号、函数名写错这三类。
5.2 运行卡顿、闪屏和按键失效的优化技巧
这套代码最大的视觉缺点是闪屏。因为system("cls")每次清屏都会让控制台闪烁,玩久了眼睛不太舒服。想改善可以试试两个方向:
第一,降低刷新时的“全屏重绘”负担。可以把地图和状态信息绘制到同一个缓冲区,再一次性输出。不过这会引入字符串拼接的复杂度,入门阶段不做也没关系。
第二,用Windows API的SetConsoleCursorPosition把光标移到左上角,而不是清屏。思路是:用GetStdHandle拿到控制台句柄,再设置光标位置到(0,0),这样每次绘制会覆盖旧画面,闪烁明显减轻。这个属于进阶玩法,我不放进基础代码里,但你在网上搜“C++控制台游戏防闪烁”能找到很多现成封装。
关于按键失效,最常见原因是暂停后缓冲区里残留了按键。我在暂停代码里用while (_kbhit()) _getch();清空了缓冲。如果你把暂停功能去掉,这个隐患就不会有。我的建议是先跑通基础版,暂停功能等熟练后再加也行。
5.3 从“能跑”到“好用”的四个扩展方向
这份源码只是地基,往上盖楼的空间很大。我给过几个朋友做扩展练习,比较推荐以下四个方向,难度从低到高:
一是计分加速。每吃到食物,得分增加并让Sleep时间减少一点。比如Sleep(max(30, 100 - score / 2));,注意要包含<algorithm>才能用max。这样游戏会越来越刺激。
二是增加关卡和障碍物。可以在地图中生成若干固定的墙,蛇撞上去就结束。墙的位置可以每关换一批,甚至用随机数生成。这个扩展能让你学会“初始化地图”和“碰撞检测”的复用。
三是历史最高分保存。把最高分写到本地文件里,下次启动时读取。这会用到文件流ifstream和ofstream,是很好的工程练习。
四是改成图形界面。接入简单的图形库,把字符替换成方块贴图。这个方向需要学习事件驱动编程,对入门者来说跨度比较大,但如果你学完基础后想挑战一下,非常值得试。
我个人在实际调这个项目时最大的体会是:贪吃蛇的代码量虽小,但每一步都有值得琢磨的决策。从数据结构的选择,到自撞检测的顺序,再到方向键的读取,任何一个地方想当然,程序就会用各种诡异的bug“回报”你。当你真正把这套逻辑理清楚,你收获的不只是一个能跑的游戏,而是面对任何实时反馈类程序时,脑子里那套清晰的问题拆解方法。
最后再分享一个小技巧:调试的时候,把地图尺寸临时改成10 x 10,速度调慢,食物固定在某个坐标。这样能很快验证移动算法是否正确。等逻辑完全没问题了,再改回正常参数。这个小技巧我每次做类似小游戏都会用,能省下不少盲猜的时间。