资讯详情

C++贪吃蛇游戏编程入门:从控制台到图形界面

📅 2026/10/17 8:57:23 | 华诺云谱 👁 阅读
C++贪吃蛇游戏编程入门:从控制台到图形界面
1. 为什么选择贪吃蛇作为游戏编程的第一课1.1 从“玩过”到“做出来”的认知跨越绝大多数人接触编程的第一个念头都是“我想做个游戏”。这个念头很朴素也很真实。但真正动手的时候问题就来了做游戏从哪儿下手是学Unity还是Unreal是搞3D还是2D要不要先学图形学这些问题一冒出来热情基本就被浇灭了一半。我个人的看法是如果你连游戏循环是什么都还没亲手写过一遍直接上引擎大概率是在学工具而不是学编程。贪吃蛇这个项目之所以经典不是因为它简单而是因为它麻雀虽小五脏俱全。一个完整的贪吃蛇里包含了游戏编程最核心的几个骨架游戏循环、状态管理、输入处理、碰撞检测、渲染刷新、分数系统。这些东西你在任何商业游戏里都能找到对应的放大版本。用C写贪吃蛇还有一个额外的好处你会被迫面对内存、数组越界、控制台光标这些底层问题。这些问题在引擎里被封装掉了但在学习阶段恰恰是这些“麻烦”让你真正理解程序在干什么。所以这个项目的定位很明确适合有C基础语法知识变量、循环、数组、函数但从未做过完整交互程序的人。你不需要会类继承、不需要懂设计模式只要能把基本语法串起来就能跟着做出来。1.2 控制台贪吃蛇的能力边界与预期管理先把预期说清楚。控制台版本的贪吃蛇画面是字符拼的蛇头是或者O食物是*或者$墙壁是#。它没有图片、没有音效、没有动画过渡。你能做的就是在一个黑框框里让蛇动起来、吃东西、变长、撞墙死掉。但别小看这个黑框框。它能教会你的东西包括如何用while循环维持一个持续运行的游戏主循环如何用Sleep()或者计时器控制帧率如何用二维数组表示游戏地图如何用kbhit()和getch()做非阻塞输入如何用system(cls)或者光标定位做画面刷新。这些技术点换到任何图形库或者引擎里概念是完全相通的。我见过不少人做完控制台贪吃蛇之后转去学EasyX或者SDL基本上两天就能把图形版做出来因为核心逻辑一行都不用改只是把cout换成了画图函数。这就是这个项目的价值它让你把精力集中在逻辑上而不是被图形API的细节淹没。1.3 开发环境与工具链的最低配置做这个项目你不需要什么高端配置。Windows下用Visual Studio Community版就行建一个空项目添加一个.cpp文件就能写。也可以用Dev-C或者CodeBlocks但VS的调试器更好用后面排查数组越界的时候你会感谢我。如果你在Linux或者Mac上用g命令行编译也完全没问题但要注意conio.h和windows.h是Windows特有的跨平台需要替换成termios或者ncurses。编译标准建议开到C11以上因为后面可能会用到std::vector或者std::pair来简化代码。但如果你还在学数组阶段用纯C风格的二维数组也完全可以不影响核心逻辑的理解。注意不要一上来就想着做“完美架构”。我见过太多人贪吃蛇还没跑起来就开始设计Snake类、Food类、GameEngine类结果类之间的关系绕晕了自己。第一版就用面向过程写把所有逻辑塞在main函数或者几个全局函数里跑通了再重构。能跑起来的烂代码比跑不起来的漂亮代码有价值一百倍。2. 核心机制拆解蛇怎么动、食物怎么长、撞墙怎么判2.1 游戏地图的表示二维数组与控制台坐标的映射贪吃蛇的地图本质上就是一个二维网格。假设我们做一个20列、15行的地图那么可以用一个int map[15][20]或者char map[15][20]来表示。每个格子的值代表这个位置上有什么0表示空1表示蛇身2表示食物3表示墙壁。但这里有一个新手最容易踩的坑控制台的坐标系和数组的坐标系是反的。数组是map[row][col]row是行对应控制台的Y轴从上往下col是列对应控制台的X轴从左往右。而我们在控制台输出的时候通常是先定位光标到(x, y)其中x是列y是行。所以你在写代码的时候脑子里要清楚map[y][x]对应屏幕上的第y行第x列。我个人的习惯是在代码里统一用x表示列、y表示行然后在访问数组的时候写成map[y][x]。这样虽然看起来有点别扭但能避免很多坐标混乱的问题。另一个做法是直接定义一个Point结构体里面放x和y然后用vectorPoint来存蛇身这样逻辑更清晰但需要你对STL有基本了解。地图的边界处理也有两种方案一种是把墙壁画在地图内部比如第0行、第0列、最后一行、最后一列都设为墙壁蛇撞上去就死另一种是不画墙壁蛇走到边界外面就死。第一种方案更直观也更容易做碰撞检测推荐新手用第一种。2.2 蛇的数据结构数组队列与移动逻辑蛇怎么表示最简单的做法是用一个数组snakeX[100]和snakeY[100]分别存蛇每一节的坐标。snakeX[0]和snakeY[0]是蛇头后面的依次是蛇身。蛇的长度用变量len记录。移动的逻辑是这样的蛇每次移动其实是把每一节的位置往前挪一格。具体操作是从蛇尾开始把第i节的位置设为第i-1节的位置一直循环到第1节最后把蛇头的位置根据当前方向更新。这个过程用代码写就是for (int i len - 1; i 0; i--) { snakeX[i] snakeX[i - 1]; snakeY[i] snakeY[i - 1]; } // 根据方向更新蛇头 if (dir 0) snakeY[0]--; // 上 else if (dir 1) snakeX[0]; // 右 else if (dir 2) snakeY[0]; // 下 else if (dir 3) snakeX[0]--; // 左这个逻辑看起来很简单但有一个细节要注意如果你从蛇头开始挪蛇头会把蛇身覆盖掉蛇就变成一条直线了。所以必须从蛇尾开始倒着挪。这个坑我当年踩过蛇一动就只剩一个头找了半天才发现是循环方向反了。吃食物的逻辑也不复杂每次移动后判断蛇头坐标是否和食物坐标重合。如果重合说明吃到了这时候蛇的长度加一并且不删除蛇尾因为上面的移动逻辑已经把所有节往前挪了如果不加长度蛇尾那一节就空出来了相当于蛇没变长。然后重新随机生成一个食物位置确保食物不出现在蛇身上。2.3 食物生成与碰撞检测的细节处理食物生成看起来就是随机一个坐标但有几个细节要注意。第一食物不能生成在蛇身上否则蛇一吃就出问题。所以生成之后要遍历蛇身检查如果撞上了就重新生成。第二食物不能生成在墙壁上如果墙壁是地图的一部分那随机范围就要避开墙壁所在的行和列。第三食物最好离蛇头有一定距离不然刚吃完又立刻吃到游戏体验会很奇怪但这个不是必须的。碰撞检测分三种撞墙、撞自己、吃到食物。撞墙就是判断蛇头坐标是否等于墙壁坐标或者超出地图范围。撞自己就是判断蛇头坐标是否和蛇身某一节重合。这里有一个容易忽略的点蛇头不能和蛇身的第一节也就是脖子比较因为蛇头移动后脖子会移到蛇头原来的位置如果比较的话会误判。正确的做法是从第1节开始比较或者从第2节开始取决于你的移动逻辑。我一般会在移动之前先保存蛇头的新位置然后判断这个新位置是否合法如果合法再执行移动。这样逻辑更清晰也更容易调试。实操心得在开发阶段可以把碰撞检测的判定条件放宽一点比如撞墙不死只是穿墙到对面这样方便你测试蛇的移动和吃食物逻辑。等核心逻辑没问题了再把死亡判定加回来。这种“分阶段调试”的思路在做任何游戏的时候都适用。3. 从零到一完整代码实现与逐段解析3.1 头文件、全局变量与初始化设置先把需要的头文件列出来。Windows下需要iostream、conio.h、windows.h、ctime。conio.h提供kbhit()和getch()windows.h提供Sleep()和光标定位的SetConsoleCursorPosition()。ctime用来给随机数播种。#include iostream #include conio.h #include windows.h #include ctime using namespace std; const int WIDTH 20; const int HEIGHT 15; int snakeX[100], snakeY[100]; int len 3; int foodX, foodY; int dir 1; // 0上 1右 2下 3左 int score 0; bool gameOver false;这里WIDTH和HEIGHT是地图的宽和高不包括墙壁。蛇的初始长度设为3方向设为向右。score记录分数gameOver控制游戏循环是否继续。初始化函数要做几件事设置蛇的初始位置比如在地图中间横向排开三节随机生成第一个食物隐藏光标不然光标闪来闪去很影响观感。void init() { // 蛇初始位置 int startX WIDTH / 2; int startY HEIGHT / 2; for (int i 0; i len; i) { snakeX[i] startX - i; snakeY[i] startY; } // 随机种子 srand((unsigned)time(NULL)); // 生成食物 generateFood(); // 隐藏光标 HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cci; GetConsoleCursorInfo(hOut, cci); cci.bVisible FALSE; SetConsoleCursorInfo(hOut, cci); }隐藏光标这段代码看起来有点长但其实就是获取控制台句柄然后设置光标不可见。如果你不想用Windows API也可以不隐藏只是画面上会有一个闪烁的光标不影响功能。3.2 地图绘制与光标定位刷新技巧绘制地图有两种方式一种是每次清屏重画用system(cls)简单但会闪屏另一种是用光标定位只更新变化的部分不闪屏但代码稍复杂。我推荐新手先用system(cls)因为逻辑简单等跑通了再优化。用system(cls)的绘制函数大概长这样void draw() { system(cls); // 画上墙 for (int i 0; i WIDTH 2; i) cout #; cout endl; for (int y 0; y HEIGHT; y) { cout #; for (int x 0; x WIDTH; x) { if (x snakeX[0] y snakeY[0]) cout ; // 蛇头 else if (isSnakeBody(x, y)) cout o; // 蛇身 else if (x foodX y foodY) cout *; // 食物 else cout ; } cout # endl; } // 画下墙 for (int i 0; i WIDTH 2; i) cout #; cout endl; cout Score: score endl; }isSnakeBody是一个辅助函数遍历蛇身数组判断某个坐标是否在蛇身上。这个函数会被频繁调用所以效率上要注意不过对于20x15的地图来说完全不是问题。如果你用光标定位思路是先把整个地图画一次之后每次只更新蛇头、蛇尾和食物的位置。蛇头新位置画蛇尾旧位置画空格食物位置画*。这样画面不会闪体验好很多。但要注意光标定位的坐标是(x, y)其中x是列y是行和数组的map[y][x]要对应好。3.3 输入处理非阻塞按键与方向控制kbhit()的作用是检测是否有键盘按下如果有返回非零值getch()则是读取按下的键但不回显到屏幕上。这两个函数配合使用就能实现非阻塞输入游戏循环一直在跑只有当你按键的时候才改变方向。void input() { if (_kbhit()) { int key _getch(); switch (key) { case w: case W: case 72: if (dir ! 2) dir 0; break; case d: case D: case 77: if (dir ! 3) dir 1; break; case s: case S: case 80: if (dir ! 0) dir 2; break; case a: case A: case 75: if (dir ! 1) dir 3; break; } } }这里用if (dir ! 2)这样的判断是为了防止蛇直接反向移动。比如蛇正在向右走你按左键如果不判断蛇头会直接撞到自己的脖子上游戏就出bug了。方向键的键码是72、77、80、75分别对应上、右、下、左。如果你用的是VS可能需要用_kbhit()和_getch()因为VS认为kbhit和getch不安全。注意getch()读取的是键码不是字符。方向键会产生两个字节第一个是224第二个才是具体的方向码。所以如果你用getch()读方向键实际上第一次读到的是224第二次才是72/80/75/77。上面的代码简化了处理直接判断了第二个字节但在某些编译器上可能需要连续读两次。如果你发现方向键没反应试试先读一次getch()忽略掉224。3.4 游戏主循环与帧率控制游戏主循环是整个程序的心脏。它的结构很简单只要游戏没结束就一直循环执行“输入-更新-绘制”这三步。void gameLoop() { while (!gameOver) { input(); update(); draw(); Sleep(150); // 控制速度150毫秒一帧 } }Sleep(150)决定了蛇的移动速度。数值越小蛇跑得越快。150毫秒大概是一秒移动6到7次对于新手来说比较友好。你可以根据自己喜好调整但不要低于50毫秒不然人眼反应不过来。update()函数里做三件事计算蛇头新位置、判断碰撞、执行移动和吃食物逻辑。我一般会把碰撞检测放在移动之前先算出新蛇头的位置判断这个位置是否合法如果合法再移动不合法就设置gameOver true。void update() { // 计算新蛇头位置 int newHeadX snakeX[0]; int newHeadY snakeY[0]; if (dir 0) newHeadY--; else if (dir 1) newHeadX; else if (dir 2) newHeadY; else if (dir 3) newHeadX--; // 撞墙检测 if (newHeadX 0 || newHeadX WIDTH || newHeadY 0 || newHeadY HEIGHT) { gameOver true; return; } // 撞自己检测 for (int i 0; i len; i) { if (snakeX[i] newHeadX snakeY[i] newHeadY) { gameOver true; return; } } // 移动蛇身 for (int i len - 1; i 0; i--) { snakeX[i] snakeX[i - 1]; snakeY[i] snakeY[i - 1]; } snakeX[0] newHeadX; snakeY[0] newHeadY; // 吃食物检测 if (newHeadX foodX newHeadY foodY) { len; score 10; generateFood(); } }这段代码里有一个细节撞自己检测的时候我遍历了所有蛇身节点包括蛇头自己。但因为新蛇头位置和当前蛇头位置不同除非蛇长度为1且原地不动所以不会误判。但如果你在移动之后检测蛇头已经更新了再遍历就会把自己也算进去导致永远判定为撞自己。所以碰撞检测一定要在移动之前做。3.5 分数系统与游戏结束画面分数系统很简单吃到食物加10分显示在屏幕下方。游戏结束的时候清屏显示最终分数和“Game Over”字样然后等待用户按键退出。void showGameOver() { system(cls); cout endl; cout Game Over! endl; cout Final Score: score endl; cout endl; cout Press any key to exit... endl; _getch(); }如果你想让游戏更完整可以加一个“重新开始”的选项按R键重新初始化游戏。这个扩展很简单把init()和gameLoop()再调用一次就行但要注意重置所有全局变量。4. 常见问题与排查技巧实录4.1 蛇移动异常与数组越界排查新手做贪吃蛇最常遇到的问题就是蛇移动的时候“断成两截”或者“只剩一个头”。这基本上都是移动逻辑写反了导致的。记住从蛇尾开始挪最后更新蛇头。如果你从蛇头开始挪蛇头会把第二节覆盖掉第二节又把第三节覆盖掉最后整条蛇都变成蛇头的位置。另一个常见问题是数组越界。蛇的长度最大是100但如果地图是20x15蛇最长也就300节所以100可能不够。我建议把数组大小设成WIDTH * HEIGHT这样绝对不会越界。如果你用vector那就更不用担心了push_back自动扩容。还有一个隐蔽的bug食物生成在蛇身上。这会导致蛇吃到食物后长度加一但新蛇头位置和某一节蛇身重合下一帧碰撞检测就会判定撞自己。排查方法是在生成食物后打印食物坐标和蛇身坐标看看有没有重合。如果有就在生成函数里加一个循环直到生成的位置不在蛇身上为止。4.2 按键无响应与方向键键码问题按键无响应通常有三个原因。第一kbhit()和getch()没有正确包含头文件或者VS要求用_kbhit()和_getch()。第二方向键的键码处理不对前面说过方向键会产生两个字节你可能需要读两次。第三游戏循环里Sleep()时间太长导致按键被忽略。比如你Sleep(500)半秒才检测一次按键那按快了确实没反应。我个人的建议是先用字母键WASD测试因为字母键只有一个字节处理简单。等字母键没问题了再加方向键支持。方向键的处理代码可以这样写if (_kbhit()) { int key _getch(); if (key 224) { // 方向键前缀 key _getch(); switch (key) { case 72: if (dir ! 2) dir 0; break; case 77: if (dir ! 3) dir 1; break; case 80: if (dir ! 0) dir 2; break; case 75: if (dir ! 1) dir 3; break; } } else { // 处理字母键 } }4.3 画面闪烁与刷新优化方案system(cls)刷新画面会闪这是因为清屏和重绘之间有时间差人眼能感知到。解决办法是用光标定位只更新变化的部分。具体做法是定义一个gotoxy(x, y)函数把光标移动到指定位置然后只输出那个位置的字符。void gotoxy(int x, int y) { COORD pos { (SHORT)x, (SHORT)y }; HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); SetConsoleCursorPosition(hOut, pos); }用gotoxy之后绘制逻辑变成蛇头新位置画蛇尾旧位置画空格食物位置画*其他位置不动。这样画面完全不闪而且效率高很多。但要注意蛇尾的位置需要在移动之前保存因为移动之后蛇尾就变了。还有一个优化是双缓冲在内存里维护一个完整的画面数组每次更新这个数组然后一次性输出。但控制台程序做双缓冲比较麻烦用gotoxy已经足够了。4.4 常见问题速查表问题现象可能原因排查方法解决方案蛇移动时只剩一个头移动循环方向写反检查for循环是从0到len还是从len到0改为从蛇尾开始挪按键没反应头文件或键码问题打印kbhit()返回值用_kbhit()方向键读两次吃到食物后游戏结束食物生成在蛇身上打印食物和蛇身坐标生成时循环检查避开蛇身画面闪烁严重用了system(cls)观察刷新时的闪烁改用gotoxy局部刷新蛇穿墙不死碰撞检测条件不对检查边界判断的和确保新蛇头坐标在0到WIDTH-1之间蛇反向移动撞死自己没有方向互斥判断检查按键处理逻辑加if (dir ! 相反方向)分数不增加吃食物检测位置不对检查检测是在移动前还是移动后在移动后、更新蛇头前检测独家避坑技巧在开发阶段把Sleep()设大一点比如300毫秒这样蛇移动慢你有足够时间观察每一步的逻辑是否正确。等逻辑没问题了再调回150毫秒。另外在update()函数里加一些cout调试输出打印蛇头坐标、食物坐标、当前方向能帮你快速定位问题。调试完了记得删掉或者注释掉不然会影响画面。5. 从控制台到图形界面后续扩展方向5.1 用EasyX快速升级为图形版控制台版跑通之后如果你想让它看起来更像一个“真正的游戏”最简单的路径是换到EasyX图形库。EasyX是一个基于Windows GDI的C图形库安装简单API也很直观。你只需要把cout换成fillrectangle或者putimage把gotoxy换成坐标计算核心逻辑一行都不用改。比如画蛇头原来是cout 现在变成fillrectangle(x * 20, y * 20, (x1) * 20, (y1) * 20)画一个20x20的方块。食物可以画成圆形蛇身画成绿色方块墙壁画成灰色。加上BeginBatchDraw()和EndBatchDraw()做双缓冲画面就非常流畅了。从控制台到EasyX的迁移大概只需要一两个小时。因为你的游戏逻辑已经验证过了剩下的只是渲染层的替换。这也是为什么我建议新手先做控制台版逻辑和渲染分离换渲染层的时候逻辑不用动。5.2 加入音效、关卡与排行榜的思路如果你还想继续扩展可以加音效。Windows下可以用PlaySound()播放wav文件吃到食物播放一个“叮”的声音撞墙播放一个“砰”的声音。音效文件可以在网上找免费的素材注意版权就行。关卡系统也很容易加。比如每吃5个食物蛇的速度加快一点或者地图上出现障碍物。障碍物可以用一个额外的数组表示碰撞检测的时候多判断一次。排行榜可以用文件读写把最高分存到本地txt文件里每次游戏结束的时候比较一下如果破纪录就更新。这些扩展都不难但每一个都能让你的项目更完整。我个人的建议是先把控制台版做到自己满意然后再选一个方向深入。不要一次性加太多功能不然容易烂尾。5.3 代码重构从面向过程到面向对象当你把功能都加得差不多了代码可能会变得很长几百行甚至上千行。这时候可以考虑重构把蛇、食物、地图、游戏引擎拆成不同的类。比如Snake类管理蛇的移动和增长Food类管理食物的生成Game类管理游戏循环和状态。重构的时候要注意不要为了用类而用类。如果你的代码只有两三百行面向过程完全够用强行拆类反而增加复杂度。等到代码超过五百行或者你发现多个函数都在操作同一组数据的时候再考虑封装成类。我个人的经验是贪吃蛇这个项目面向过程写一遍面向对象再写一遍你对“封装”和“职责分离”的理解会深刻很多。第一遍让你知道怎么实现功能第二遍让你知道怎么组织代码。最后分享一个小技巧如果你在Windows下用VS开发可以在项目属性里把“子系统”设为“控制台”这样程序运行的时候会弹出一个控制台窗口。如果你想让程序全屏运行可以调用SetConsoleDisplayMode()或者调整窗口大小。但这些都不是必须的默认窗口就够用了。这个项目后续还可以这样扩展把地图从固定大小改成可配置的让玩家在开始的时候选择难度加入暂停功能按空格键暂停游戏加入“穿墙模式”蛇从一边出去从另一边进来。每一个扩展都不难但都能让你对游戏编程的理解更深一层。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑