news 2026/10/16 11:01:50

C++手写贪吃蛇:从游戏循环到碰撞检测的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++手写贪吃蛇:从游戏循环到碰撞检测的完整实现

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::deque<Point> 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::deque<Point>& 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::deque<Point> 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++ -std=c++17 -O2 main.cpp snake.cpp -o snake ./snake

-std=c++17是因为我用了std::deque和std::chrono,这些在C++11之后都支持,但指定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有用得多。

最后再分享一个小技巧:写这类项目的时候,先把游戏循环的骨架搭出来,让蛇能动能画,哪怕逻辑不完整。然后再一点点往里填碰撞、食物、计分。这样你每一步都有能跑的东西,不会写到一半发现整个结构不对要推倒重来。这种"先跑通再完善"的节奏,比一开始就追求完美要高效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 11:00:18

Tomcat线程池详解:从默认实现原理到配置调优与故障排查

某天中午&#xff0c;我一个做物流查询系统的项目组同学打来电话&#xff0c;说线上两台实例出现了零星请求超时&#xff0c;监控上看到Tomcat活跃线程不高&#xff0c;队列却越积越长。我登上去一看&#xff0c;活跃线程稳定在60多&#xff0c;maxThreads配的300&#xff0c;队…

作者头像 李华
网站建设 2026/10/16 11:00:14

PLC工业锅炉过程控制设计与仿真全流程解析

接手一套工业锅炉的过程控制系统时&#xff0c;我最先做的不是打开编程软件&#xff0c;而是先问自己一个问题&#xff1a;如果锅炉突然断水&#xff0c;程序能不能在足够短的时间内把燃烧停下来&#xff0c;而不是先想着把PID调得多漂亮。这套基于PLC的工业锅炉过程控制的设计…

作者头像 李华
网站建设 2026/10/16 11:00:00

SpringBoot宿舍管理系统毕设全流程复盘:从需求建模到答辩避坑

如果你正在为毕业设计选题发愁&#xff0c;或者已经把"基于SpringBoot的宿舍管理信息系统"写进了申报表&#xff0c;却还不知道系统具体要做什么&#xff0c;这篇复盘应该能给你一条完整可落地的路线。宿舍管理系统在各大高校的毕设清单里几乎年年出现&#xff0c;原…

作者头像 李华
网站建设 2026/10/16 10:59:59

Elasticsearch 7.17.3 中文分词器 IK 安装与自定义词典实战

简介&#xff1a;面向Elasticsearch 7.17.3的IK中文分词器离线安装包&#xff0c;适用于需要为Windows环境下的ES集群配置中文分词能力的开发与运维人员。针对官网下载不稳定、网络受限等常见问题&#xff0c;作者将完整插件目录打包为zip&#xff0c;解压后放入ES plugins目录…

作者头像 李华
网站建设 2026/10/16 10:59:42

机器人关节挡圈垫片波簧选型与装配实战指南

1. 项目缘起与整体设计思路机器人关节模组里&#xff0c;挡圈、垫片、波簧这三类零件常被归入“标准件”或“小五金”&#xff0c;采购单上往往排在电机、减速器、编码器之后&#xff0c;存在感极低。但真正做过关节装配和失效分析的人都知道&#xff0c;这三类零件一旦选错、装…

作者头像 李华