控制台贪吃蛇从能跑到能玩:环形数组、状态机与定点重绘实战

发布时间:2026/10/23 19:17:36
控制台贪吃蛇从能跑到能玩:环形数组、状态机与定点重绘实战

上一篇文章,我们把控制台贪吃蛇最基础的版本跑通了:蛇能动、食物能刷、撞墙能结束。但那个版本距离“一个能玩的游戏”还差得远。如果你真把它拿给朋友试玩,多半会被吐槽三件事:按键卡顿、画面闪烁、蛇稍微一长就找不着头在哪。这篇就接着上一篇往下写,目标很实在:把控制台贪吃蛇从“能跑”升级到“能玩”,顺便把代码结构理顺,让后面再加功能不会越改越乱。文中代码我用C/C++混合风格写,Windows控制台环境可以直接用,拿到Linux下把conio.h相关部分换成termios也能跑通。

1. 从第一版到第二版:这次改造的核心思路

1.1 第一版留下的三个坑

第一版贪吃蛇的代码结构,估计写过的人都差不多:一个while(true)主循环,循环里读键盘、更新蛇头坐标、用system("cls")清屏再重新画整个地图。优点是逻辑直白,两三屏代码就能把“蛇会动、能吃食物、会撞墙”讲完,作为第一篇的收尾绰绰有余。但如果你照着那个版本继续加功能,很快会碰到三堵墙。

第一堵墙是画面闪烁。system("cls")每次调用都会把整个控制台缓冲区清掉,然后你再重新输出几十上百个字符。操作一多,光标来回跳动,屏幕上就像按了频闪灯一样。蛇短的时候还好,蛇身长到二三十节,每一帧要重绘太多字符,闪烁感会明显到让人不想玩。

第二堵墙是速度控制不准。第一版最常用Sleep(interval)控制每一帧的间隔,但这个写法有个隐藏问题:Sleep只保证“至少等这么多毫秒”,它不计入游戏逻辑本身的耗时。你移动蛇、绘制画面、读取按键都要花时间,把这些时间加上去,实际每帧间隔会比设定的interval长,而且长短不稳定。结果就是蛇的速度忽快忽慢,吃食物加速的逻辑也没法做准。

第三堵墙是数据结构太脆弱。很多人习惯用std::vector存蛇身,每次移动先insert新头再pop_back删尾巴。功能上没问题,但所有逻辑全堆在主循环里,代码长了之后,哪里动了蛇身、哪里改了状态,根本理不清。第二篇我不想急着上多复杂的算法,先把这些问题解决掉,后面写计分、菜单、难度才会轻松。

1.2 第二版的目标与功能清单

这次改造我给自己列了五条硬性目标:

  • 蛇身用固定数组加环形队列思想来维护,彻底摆脱频繁的内存操作;
  • 放弃system("cls"),改成光标定位加定点重绘,消灭闪烁;
  • 引入固定时间戳计时替代Sleep,让速度控制可预期;
  • 加入菜单、暂停、结束、再来一局,用状态机管理游戏流程;
  • 增加计分和难度递进,让游戏有“越玩越难”的反馈感。

这五条做完,这个项目就不再是“教学Demo”了,它更像一个可以拿来消遣、可以继续扩展的小成品。下面我按模块拆开讲,每段都会给出关键代码和踩坑说明。

2. 蛇身数据结构:环形数组与队列思想

2.1 为什么蛇的移动本质上是个队列

贪吃蛇每走一步,做的事情可以拆成两步看:先算出新蛇头要去的位置,如果新位置不是食物,就把尾巴那一节删掉。这正好符合队列“头进尾出”的模型。

用std::deque当然能做,但控制台项目里我更推荐用定长数组加head/tail两个下标来模拟,理由有两个:第一,你不需要引入STL容器,纯C环境也能跑,方便理解底层原理;第二,环形数组的访问是O(1)的,画蛇身也只需要从尾巴遍历到头部,代码非常直观。

先定义一下蛇的结构体:

const int MAX_BODY = 500; // 地图最多能容纳的蛇身节数 struct Snake { int x[MAX_BODY]; int y[MAX_BODY]; int head; // 蛇头所在数组下标 int tail; // 蛇尾所在数组下标 int len; // 当前蛇身长度 };

初始化时,我们让蛇头朝右,从(startX, startY)开始往左摆两节身体。这里有个容易绕晕的细节:环形数组的“头”和“尾”不是固定在0号下标,而是靠head和tail两个游标动态移动。

void initSnake(Snake& s, int startX, int startY) { // 蛇身三节,从右往左排 s.x[0] = startX + 2; s.y[0] = startY; // 最后一节(尾巴) s.x[1] = startX + 1; s.y[1] = startY; s.x[2] = startX; s.y[2] = startY; // 蛇头 s.tail = 0; s.head = 2; s.len = 3; }

2.2 入队与出队:新头进,尾巴出

移动时,新蛇头应该写到紧挨着当前head的下一个位置,也就是(head + 1) % MAX_BODY。取模的意思是,数组下标到末尾时就回绕到开头,形成一个环。

这里还要区分两种情况:吃到食物时,蛇身要增长,尾巴不动;没吃到食物时,尾巴跟着前进一格,保持长度不变。

void moveSnake(Snake& s, int newX, int newY, bool grow) { int newHead = (s.head + 1) % MAX_BODY; s.x[newHead] = newX; s.y[newHead] = newY; s.head = newHead; if (!grow) { // 没吃东西,尾巴往前挪一格 s.tail = (s.tail + 1) % MAX_BODY; } else { s.len++; } }

这段代码是整条游戏的“发动机”,逻辑很简单,但写的时候必须注意顺序。我之前犯过把tail更新放在head更新之前的错,结果新头写入还没完成,尾巴先动了,下一次画蛇身时坐标就对不上,画面直接乱掉。

2.3 画蛇身:从尾巴遍历到头部

绘制蛇身时,从tail开始,依次访问(tail + 1) % MAX_BODY、(tail + 2) % MAX_BODY……直到遍历完len个节点。每次取模都保证下标不会越界。

void drawSnake(const Snake& s) { int idx = s.tail; for (int i = 0; i < s.len; i++) { gotoxy(s.x[idx], s.y[idx]); SetConsoleTextAttribute(handle, 10); // 绿色 std::cout << "■"; idx = (idx + 1) % MAX_BODY; } }

注意:用■这类全角字符时,一个字符占两个字节,控制台光标定位必须落在正确坐标上,否则画出来的蛇身会出现错位。建议把地图边界和蛇身都按“字符数”来算坐标,不要和字节数混淆。

2.4 判断蛇头是否撞到自己

自碰检测是贪吃蛇最容易写错的地方。直觉做法是遍历整条蛇身,看新头坐标是否和某一节重叠。但有一个特殊情况:如果新头正好落在尾巴即将移开的位置,那不算撞到自己,因为这一节马上要被出队了。

所以正确的遍历范围是“除了尾巴以外的所有节点”,或者换个思路:先判断没吃到食物时,尾巴会先移走,再检查新头是否与剩余身体重叠。

bool hitSelf(const Snake& s, int nx, int ny, bool grow) { int count = grow ? s.len : s.len - 1; int idx = s.tail; for (int i = 0; i < count; i++) { if (s.x[idx] == nx && s.y[idx] == ny) { return true; } idx = (idx + 1) % MAX_BODY; } return false; }

这个“是否排除尾巴”的细节,很多人在第一次写时都会漏。如果你发现蛇总是莫名其妙死在“明明尾巴要离开的位置”,多半就是这里的问题。

3. 输入控制与渲染优化:手感提升的关键

3.1 方向键的读取:两个字节的坑

Windows控制台下,方向键不是普通字符,按下后会返回两个字节:第一个字节是224(或0),第二个才是真正表示方向的扫描码。只用_getch()读一次会拿到224,直接判断方向必然失败。

我见过不少人卡在这个问题上一两个小时,其实处理起来很简单:

int readDirection() { if (!_kbhit()) return -1; int key = _getch(); if (key == 224 || key == 0) { key = _getch(); // 再读一次,拿到真实按键 } switch (key) { case 72: return DIR_UP; case 80: return DIR_DOWN; case 75: return DIR_LEFT; case 77: return DIR_RIGHT; default: return -1; } }

如果你在Linux下做移植,方向键序列是\x1B[A这样的转义序列,也是三个字节,原理相通。

3.2 防反转:用异或判断正反方向

贪吃蛇最经典的限制:不能直接掉头。当前正在向右走的时候,玩家按左键应该被忽略。如果不用任何技巧,你得写四个if判断,然后又臭又长。这里有个很聪明的位运算方法。

给方向编号:

enum Direction { DIR_UP = 0, // 上 DIR_DOWN = 1, // 下 DIR_LEFT = 2, // 左 DIR_RIGHT = 3 // 右 };

巧合的是,0 ^ 1 = 1,1 ^ 1 = 0,2 ^ 1 = 3,3 ^ 1 = 2。也就是说,上下相对、左右相对,恰好可以用dir ^ 1表示“反方向”。判断时可以这么写:

int newDir = readDirection(); if (newDir != -1 && newDir != (curDir ^ 1)) { curDir = newDir; }

这个写法不管当前方向是哪个,只要新方向是当前方向的反向,就一定等于curDir ^ 1,直接忽略即可。代码整洁,逻辑也不容易出错。

3.3 摆脱system("cls"):光标定位与定点重绘

第二版最重要的一步,是把整屏重绘改成局点更新。既然蛇每次移动只影响三个位置:新蛇头、旧蛇尾、可能变化的新食物,那理论上每帧只需要在这些位置上做擦除和重画。

Windows下定位光标用SetConsoleCursorPosition:

void gotoxy(int x, int y) { COORD pos; pos.X = x; pos.Y = y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); }

再配合一个隐藏光标函数,视觉效果会干净很多:

void hideCursor() { CONSOLE_CURSOR_INFO cci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cci); cci.bVisible = FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cci); }

有了这两个函数,移动蛇的时候就可以精准操作:

// 每次update之后: // 1. 画新蛇头 gotoxy(newX, newY); SetConsoleTextAttribute(handle, 10); std::cout << "■"; // 2. 如果没吃到食物,擦掉旧尾巴 if (!ate) { gotoxy(oldTailX, oldTailY); std::cout << " "; // 两个空格,相当于擦除 } // 3. 如果吃到食物,画新食物(位置变了) if (ate) { drawFood(); }

这种方法下,画面不再全书刷新,闪烁几乎消失,蛇长到50节也不会有视觉卡顿。

3.4 固定时间戳:让速度控制真正可控

用Sleep(interval)控制帧率的最大问题是,它只负责“睡”,不负责“校准”。假设每帧游戏逻辑要跑5毫秒,你设置Sleep(100),实际每帧间隔就是105毫秒,误差会累积,蛇会越走越快或者忽快忽慢。

解决办法是记录上一次更新的时间戳,每次循环检查当前时间是否已经超过固定间隔:

DWORD lastTick = GetTickCount(); int interval = 150; // 毫秒 while (running) { DWORD now = GetTickCount(); if (now - lastTick >= interval) { updateGame(); lastTick = now; } render(); // 每帧都渲染,保证按键响应及时 }

注意render()不能放在if块里。因为按键响应需要在空闲时间也能被处理,如果把渲染也卡进定时逻辑,按一下方向键可能要等一个间隔周期才有反应,手感非常差。

暂停功能实现时记得做一件事:恢复暂停的瞬间,把lastTick重新赋值为GetTickCount()。否则暂停期间流逝的时间会被算进去,一恢复游戏蛇就往前猛跳一大步。

4. 游戏状态机与完整项目骨架

4.1 为什么要把游戏状态拆开

第一版把所有逻辑写在一个循环里,菜单、暂停、结束全都塞进去会让代码变成一团乱麻。更好的做法是抽象出一个“状态机”,让程序知道自己当前在哪个界面,并根据状态决定该执行哪些逻辑。

定义状态:

enum GameState { STATE_MENU = 0, // 主菜单 STATE_RUN, // 游戏中 STATE_PAUSE, // 暂停 STATE_OVER // 游戏结束 };

主循环变成这样:

int main() { initGame(); while (true) { switch (state) { case STATE_MENU: handleMenu(); break; case STATE_RUN: handleRun(); break; case STATE_PAUSE: handlePause(); break; case STATE_OVER: handleOver(); break; } } return 0; }

每次只处理当前状态下的按键和绘制,逻辑边界非常清晰。菜单里按空格进入游戏,游戏中按空格暂停,暂停中按空格恢复,结束后按R重新开始。状态切换通过简单的赋值实现。

4.2 菜单、暂停、结束三个辅助界面

菜单界面可以画一个简单的标题和选项:

void drawMenu() { gotoxy(MAP_WIDTH / 2 - 5, MAP_HEIGHT / 2 - 2); std::cout << "贪吃蛇 v2.0"; gotoxy(MAP_WIDTH / 2 - 8, MAP_HEIGHT / 2); std::cout << "1. 简单 2. 普通 3. 困难"; gotoxy(MAP_WIDTH / 2 - 6, MAP_HEIGHT / 2 + 1); std::cout << "空格键开始"; }

这里顺便把难度选择做了:按下1、2、3分别把interval设为200、150、100,对应慢、中、快三档。菜单事件处理放在handleMenu()里,读取按键并修改全局状态。

暂停界面不用动地图,只需要在画面中央显示“暂停中”几个字,然后等待空格键。注意暂停时必须保留当前的地图画面,不能清除,否则玩家恢复后看不到蛇在哪里。

结束界面可以显示最终得分,并提示按R重新开始。这里有两个细节:一个是重新开始前要重新初始化所有全局变量,另一个是必须把lastTick也重置,否则新游戏第一帧会立刻触发一次更新。

4.3 计分与难度递进

计分规则很简单:每吃一个食物加10分。在右侧画一个记分板,用刚才的gotoxy精确绘制。这里有个容易忽略的地方:记分板区域和游戏地图区域最好分开管理,比如地图占左边60列,记分板占右边20列,避免坐标重叠。

难度递进我用“分数每增加50分,移动间隔减少10毫秒”的规则,同时设置一个下限,防止蛇快得完全没法操作:

if (score > 0 && score % 50 == 0 && interval > 60) { interval -= 10; }

实际体验下来,60毫秒是普通玩家能接受的极限,再快就需要用到“预测下一个按键”的技巧了。

5. 实操过程:搭一个能玩的控制台版本

5.1 关键变量和初始化流程

为了保证所有函数都能访问到需要的数据,我建议用全局变量组织核心数据,数量不多,可控:

Snake snake; int foodX, foodY; int score; int curDir; int interval; GameState state; DWORD lastTick; HANDLE handle;

初始化时依次做:获取控制台句柄、隐藏光标、初始化蛇的位置、随机生成第一个食物、设置初始状态为菜单。随机函数记得用srand((unsigned)time(NULL))给种子,否则每次运行食物位置都一样。

5.2 游戏循环的update实现

handleRun()是核心函数,里面做三件事:读方向、判断是否该更新、更新画面。

void handleRun() { int newDir = readDirection(); if (newDir != -1 && newDir != (curDir ^ 1)) { curDir = newDir; } DWORD now = GetTickCount(); if (now - lastTick < (DWORD)interval) { return; // 时间没到,只处理按键 } lastTick = now; // 计算新头位置 int dx[4] = {0, 0, -1, 1}; int dy[4] = {-1, 1, 0, 0}; int nx = snake.x[snake.head] + dx[curDir]; int ny = snake.y[snake.head] + dy[curDir]; // 撞墙检测 if (nx < 0 || nx >= MAP_WIDTH || ny < 0 || ny >= MAP_HEIGHT) { state = STATE_OVER; drawOver(); return; } bool eat = (nx == foodX && ny == foodY); // 自碰检测 if (hitSelf(snake, nx, ny, eat)) { state = STATE_OVER; drawOver(); return; } // 记录旧尾巴坐标,用于擦除 int oldTailX = snake.x[snake.tail]; int oldTailY = snake.y[snake.tail]; // 移动蛇身 moveSnake(snake, nx, ny, eat); // 绘制新蛇头 gotoxy(nx, ny); SetConsoleTextAttribute(handle, 10); std::cout << "■"; if (eat) { score += 10; updateScoreBoard(); // 食物被吃了,重新生成 spawnFood(); drawFood(); // 难度递进 if (score % 50 == 0 && interval > 60) { interval -= 10; } } else { // 没吃到食物,擦除尾巴 gotoxy(oldTailX, oldTailY); std::cout << " "; } }

这段代码基本就是第二版的核心了,顺序上有个关键点:必须先判断碰撞,再执行移动。如果把移动放在检测之前,蛇头已经撞墙了,你还要再倒回去处理,逻辑会变得很别扭。

5.3 食物生成的安全策略

最简单的食物生成方式是循环随机:

do { foodX = rand() % MAP_WIDTH; foodY = rand() % MAP_HEIGHT; } while (isBody(foodX, foodY));

这在小地图、蛇身短的时候没问题,但当蛇身占满大半地图时,随机位置很可能反复落在蛇身上,碰撞检测次数急剧上升,极端情况下程序会卡到看起来像死循环。

更稳妥的写法是:先把所有空地收集起来,再从空地里随机挑一个。这里给个实现:

void spawnFood() { int freeCells = 0; for (int y = 0; y < MAP_HEIGHT; y++) { for (int x = 0; x < MAP_WIDTH; x++) { if (!isBody(x, y)) freeCells++; } } if (freeCells == 0) { // 地图被占满,游戏胜利 state = STATE_OVER; return; } int target = rand() % freeCells; for (int y = 0; y < MAP_HEIGHT; y++) { for (int x = 0; x < MAP_WIDTH; x++) { if (!isBody(x, y)) { if (target == 0) { foodX = x; foodY = y; return; } target--; } } } }

这个函数的时间复杂度是O(地图大小),即使蛇身很长也稳定。游戏快通关时,这个函数能保证食物一定生成在可到达的位置。

5.4 完整的绘制函数组织

把绘制代码统一封装,能有效避免零散输出。我通常按层次组织四个绘制函数:

  • drawBorder():只画一次,放在初始化阶段,之后不清除;
  • drawSnake():更新蛇时调用,画整个蛇身,适合初始化以及重置后重绘;
  • drawFood():画当前位置的食物;
  • drawUI():在右侧画分数、难度、操作提示。

初始化时调用一次全部绘制,之后每帧只做局部更新,这样代码清晰,也不会因为忘画某个元素导致画面残缺。

6. 常见问题与调试心得

6.1 蛇头画出来了,但身体断节或者重叠

这个问题十有八九出在环形数组的取模和head/tail更新顺序上。记住一个口诀:先写新头到(head+1)%MAX_BODY,再移动head游标,最后才根据是否吃到食物去动tail。如果先动了tail或者head,下一次绘制时遍历的起点就会错乱。

排查时可以打印head、tail和len三个值,蛇动几帧后再对比数组里每个坐标,很快就能定位是哪个环节出了问题。

6.2 方向键没反应,或者按一次走两步

方向键没反应,优先检查有没有处理224/0这个前导字节。只读一次_getch()拿到的根本不是一个有效方向键。

按一次走两步,多半是按键的“重复触发”。如果你在循环里每次都调用readDirection(),键盘按住时系统会连续产生重复按键事件。解决方法是维护一个“本帧是否已处理过方向”的标志,每帧只允许修改一次方向,或者干脆用“方向缓冲”,只在方向键第一次生效。

6.3 速度忽快忽慢

前面提过,别用Sleep做速度控制,要用时间戳判断。如果你已经用了时间戳还是忽快忽慢,注意检查render()里有没有执行耗时大的操作,比如每次渲染都遍历整个地图或者调用SetConsoleTextAttribute太多。优化思路是把颜色设置也缓存下来,只在变化时才调用。

6.4 蛇吃到自己的身体竟然没死

最可能的原因是自碰检测时忘了考虑“尾巴即将移走”的情况。当蛇没有吃到食物时,新的蛇头可以合法地移动到当前尾巴的位置,因为那一帧尾巴会立刻移开。如果检测函数把尾巴计入身体,游戏会在本该活着的时候判死。反过来,如果吃到食物时尾巴不移走,你确实应该判死。

所以检测函数的第三个参数grow必须精确传达“这一帧蛇是否增长”的状态,两个分支处理方式不同。

6.5 我给新手的排查技巧:单步调试模式

调试游戏逻辑比调试普通程序难点在于画面一直在变。我的做法是加一个隐藏的“单步模式”:按P键进入暂停,按N键手动执行一次update,然后打印出蛇头坐标、方向、长度和下一帧新头位置。

if (key == 'n' && state == STATE_PAUSE) { updateGame(); drawGame(); }

这个功能在排查“为什么这里撞了”“为什么那里没撞”时极其有效,你可以一帧一帧地观察蛇的状态变化。等所有逻辑都调通了,再把这个调试分支注释掉。

在我看来,控制台贪吃蛇这个项目写到这里,已经不是“能不能跑”的问题,而是“好不好玩”的问题。环形数组把蛇身管理变得稳定,定点重绘消灭了闪烁,固定时间戳让加速减速都变得可控,状态机又给了你扩展菜单和暂停的底气。这一套思维不止适用于贪吃蛇,你随便拿一个控制台小游戏,套上“状态机+定时更新+局部重绘”这个框架,手感都会比瞎写一气的版本强很多。后面如果再想加东西,最高分存档、蛇的AI自动寻路、双人模式,都只是在这个骨架上面继续长肌肉了。